From b6f56d364dde40317cca6b651540042609127e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 24 Aug 2026 01:51:55 +0200 Subject: [PATCH] =?UTF-8?q?test(fuzz):=20couvrir=20les=20entr=C3=A9es=20qu?= =?UTF-8?q?e=20le=20moteur=20ne=20produit=20pas=20(0.1.64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dépôt fuzzait les deux fichiers du contrat déclaratif, ce qui est la bonne intuition mais pas la couverture complète : le contexte local et les outputs de Terraform sont lus avec la même confiance, et n'étaient couverts par aucun harnais. Chaque entrée est non fiable pour une raison différente, et cette raison décide du contrat que son harnais assère. .dsoxlab-context.json vit sur le disque de l'apprenant : édité à la main, tronqué par un portable refermé au mauvais moment, laissé par une version ancienne. Son harnais n'a aucune exception de contrat, et c'est tout son propos : read_context promet de rendre un contexte vide plutôt que de lever, parce que perdre le contexte coûte un dsoxlab use là où une exception coûte la CLI entière. Les outputs Terraform viennent d'un binaire externe dont la version, les providers et le schéma de sortie bougent sans que dsoxlab le sache. Le harnais vise ce que build_inventory fait du document, et non le json.loads qui le précède : celui-là est déjà protégé, et le fuzzer n'y mesurerait que la bibliothèque standard. Il a trouvé un vrai défaut à sa troisième minute : {"hosts": {"value": "10.0.0.1"}} faisait lever un AttributeError au moment de jouer un lab, sans jamais dire que la cause était un state Terraform périmé. Corrigé, et figé par un test : le fuzzing découvre, un test empêche le retour. Le harnais m'a aussi appris son propre contrat : InfraNotProvisioned est une exception attendue, celle du premier lancement ou de l'après-destroy, et un harnais qui l'aurait comptée comme un crash aurait réclamé de la défaire. Closes #71 --- .github/workflows/ci.yml | 42 ++++++- .pre-commit-config.yaml | 10 +- CHANGELOG.fr.md | 34 ++++++ CHANGELOG.md | 32 +++++ fuzz/corpus/context_json/champ-inconnu.json | 1 + fuzz/corpus/context_json/complet.json | 1 + .../context_json/position-negative.json | 1 + .../context_json/position-non-numerique.json | 1 + fuzz/corpus/context_json/racine-chaine.json | 1 + fuzz/corpus/context_json/racine-liste.json | 1 + fuzz/corpus/context_json/racine-nulle.json | 1 + fuzz/corpus/context_json/section-liste.json | 1 + fuzz/corpus/context_json/tronque.json | 1 + fuzz/corpus/context_json/vide.json | 1 + .../terraform_outputs/avec-bastion.json | 1 + .../terraform_outputs/bastion-sans-ip.json | 1 + .../corpus/terraform_outputs/hosts-liste.json | 1 + .../terraform_outputs/ip-numerique.json | 1 + .../terraform_outputs/outputs-aplatis.json | 1 + .../terraform_outputs/outputs-encapsules.json | 1 + fuzz/corpus/terraform_outputs/sans-hosts.json | 1 + .../terraform_outputs/value-non-objet.json | 1 + fuzz/corpus/terraform_outputs/vide.json | 1 + fuzz/fuzz_context_json.py | 60 +++++++++ fuzz/fuzz_terraform_outputs.py | 115 ++++++++++++++++++ pyproject.toml | 2 +- src/dsoxlab/infra/inventory.py | 13 +- tests/test_outputs_terraform.py | 97 +++++++++++++++ uv.lock | 2 +- 29 files changed, 414 insertions(+), 12 deletions(-) create mode 100644 fuzz/corpus/context_json/champ-inconnu.json create mode 100644 fuzz/corpus/context_json/complet.json create mode 100644 fuzz/corpus/context_json/position-negative.json create mode 100644 fuzz/corpus/context_json/position-non-numerique.json create mode 100644 fuzz/corpus/context_json/racine-chaine.json create mode 100644 fuzz/corpus/context_json/racine-liste.json create mode 100644 fuzz/corpus/context_json/racine-nulle.json create mode 100644 fuzz/corpus/context_json/section-liste.json create mode 100644 fuzz/corpus/context_json/tronque.json create mode 100644 fuzz/corpus/context_json/vide.json create mode 100644 fuzz/corpus/terraform_outputs/avec-bastion.json create mode 100644 fuzz/corpus/terraform_outputs/bastion-sans-ip.json create mode 100644 fuzz/corpus/terraform_outputs/hosts-liste.json create mode 100644 fuzz/corpus/terraform_outputs/ip-numerique.json create mode 100644 fuzz/corpus/terraform_outputs/outputs-aplatis.json create mode 100644 fuzz/corpus/terraform_outputs/outputs-encapsules.json create mode 100644 fuzz/corpus/terraform_outputs/sans-hosts.json create mode 100644 fuzz/corpus/terraform_outputs/value-non-objet.json create mode 100644 fuzz/corpus/terraform_outputs/vide.json create mode 100755 fuzz/fuzz_context_json.py create mode 100755 fuzz/fuzz_terraform_outputs.py create mode 100644 tests/test_outputs_terraform.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee991af..4dff415 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -418,17 +418,30 @@ jobs: - name: Run every hook (push stage) run: uv run pre-commit run --all-files --hook-stage pre-push --show-diff-on-failure --color always - # Short fuzzing regression over the untrusted-YAML contract. lab.yaml and - # meta.yml come from lab-provider repositories, so they are this engine's - # main untrusted input; discovery/scanner.py only rattrape (KeyError, - # ValueError, YAMLError), and anything else crashes the CLI on an unrelated - # command. These harnesses assert that contract. + # Short fuzzing regression over every input the engine does NOT produce + # itself. Each one is untrusted for a different reason, and the reason is what + # decides the contract each harness asserts: + # + # lab.yaml, meta.yml written by a lab-provider repository. + # discovery/scanner.py only catches KeyError, + # ValueError and YAMLError; anything else crashes + # the CLI on an unrelated command. + # .dsoxlab-context.json written by `use`, but living on the learner's + # disk: hand-edited, truncated by a laptop closed + # mid-write, left over by an older version. + # read_context must NEVER raise, so that harness + # has no contract exception at all. + # terraform outputs produced by an external binary whose version, + # providers and output schema all move without + # dsoxlab knowing. The harness targets what + # build_inventory does with the decoded document, + # not the json.loads that precedes it. # # This is a regression gate, not a fuzzing campaign: a short seeded run keeps # the harnesses alive and catches the obvious breakages. Deep runs are done # locally with a persistent corpus. fuzz: - name: Fuzz the untrusted-YAML contract (short run) + name: Fuzz every untrusted input (short run) needs: [zizmor, actionlint, poutine] runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -472,6 +485,23 @@ jobs: -dict=fuzz/dict/yaml_contract.dict \ -atheris_runs=20000 -max_len=4096 + # Pas de -dict ici : yaml_contract.dict porte des mots-clés du contrat + # YAML, sans rapport avec un document JSON. Un dictionnaire hors sujet + # coûte du budget sans guider la mutation. + - name: Fuzz the local session context + run: | + mkdir -p "${RUNNER_TEMP}/fuzz-context" + uv run --group fuzz python fuzz/fuzz_context_json.py \ + "${RUNNER_TEMP}/fuzz-context" fuzz/corpus/context_json/ \ + -atheris_runs=20000 -max_len=4096 + + - name: Fuzz the Terraform outputs the inventory is built from + run: | + mkdir -p "${RUNNER_TEMP}/fuzz-tfout" + uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \ + "${RUNNER_TEMP}/fuzz-tfout" fuzz/corpus/terraform_outputs/ \ + -atheris_runs=20000 -max_len=4096 + # A crash writes a `crash-*` reproducer next to the harness. Keep it: it # is the exact input needed to replay the failure locally. - name: Upload crash reproducers diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e56de6..684d651 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,10 +20,18 @@ repos: hooks: - id: trailing-whitespace exclude: '(^LICENSE$|\.md$)' + # `fuzz/corpus/` est hors du périmètre de l'hygiène, et pas par + # commodité : ce sont des graines DÉLIBÉRÉMENT malformées, qui existent + # pour être données à un fuzzer. Un JSON tronqué au milieu d'une chaîne + # reproduit un portable refermé pendant l'écriture ; le « réparer » ou le + # refuser retire au corpus le cas qu'il porte. Le contrat des documents, + # lui, est tenu par les harnais eux-mêmes, pas par ces hooks. - id: end-of-file-fixer - exclude: '^LICENSE$' + exclude: '(^LICENSE$|^fuzz/corpus/)' - id: check-yaml + exclude: '^fuzz/corpus/' - id: check-json + exclude: '^fuzz/corpus/' - id: check-toml - id: check-merge-conflict - id: check-case-conflict diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index e4e8c8e..541f741 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,40 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.64] - 2026-08-24 + +### Corrigé + +- **Un output Terraform mal formé faisait planter la construction de + l'inventaire.** `{"hosts": {"value": "10.99.0.11"}}` suffisait : le code + prenait la valeur pour un objet et appelait `.items()` dessus, ce qui rendait + un `AttributeError` au moment de jouer un lab, sans jamais dire que la cause + était un state Terraform périmé. Ce document fait trente-quatre octets et + personne ne l'avait écrit à la main : c'est le nouveau harnais de fuzzing qui + l'a trouvé, en moins de trente mille exécutions. + +### Ajouté + +- **Le fuzzing couvre désormais toutes les entrées que le moteur ne produit pas + lui-même.** Deux harnais s'ajoutent aux deux existants, et chacun assère un + contrat différent, parce que chaque entrée est non fiable pour une raison + différente : + + - **`.dsoxlab-context.json`** vit sur le disque de l'apprenant : édité à la + main par curiosité, tronqué par un portable refermé au mauvais moment, + laissé par une version ancienne. Son harnais n'a **aucune exception de + contrat**, et c'est tout son propos : `read_context` promet de rendre un + contexte vide plutôt que de lever, parce que perdre le contexte coûte un + `dsoxlab use` alors qu'une exception coûte la CLI entière. + - **Les outputs Terraform** viennent d'un binaire externe dont la version, + les providers et le schéma de sortie bougent sans que dsoxlab le sache. Le + harnais vise ce que `build_inventory` **fait** du document, et non le + `json.loads` qui le précède : celui-là est déjà protégé, et le fuzzer n'y + mesurerait que la bibliothèque standard. + + Le commentaire du job de CI énumère les entrées couvertes et dit, pour + chacune, pourquoi elle n'est pas fiable. + ## [0.1.63] - 2026-08-24 - **La documentation décrivait un produit qui n'existe pas.** Trois diff --git a/CHANGELOG.md b/CHANGELOG.md index e50c4c0..15595f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.64] - 2026-08-24 + +### Fixed + +- **A malformed Terraform output crashed the inventory build.** + `{"hosts": {"value": "10.99.0.11"}}` was enough: the code took the value for a + mapping and called `.items()` on it, producing an `AttributeError` at the very + moment someone runs a lab, and never saying that the cause was a stale + Terraform state. That document is thirty-four bytes and nobody had written it + by hand: the new fuzz harness found it in under thirty thousand runs. + +### Added + +- **Fuzzing now covers every input the engine does not produce itself.** Two + harnesses join the two existing ones, and each asserts a different contract, + because each input is untrusted for a different reason: + + - **`.dsoxlab-context.json`** lives on the learner's disk: hand-edited out of + curiosity, truncated by a laptop closed mid-write, left behind by an older + version. Its harness has **no contract exception at all**, and that is the + whole point: `read_context` promises to return an empty context rather than + raise, because losing the context costs one `dsoxlab use` where an exception + costs the entire CLI. + - **Terraform outputs** come from an external binary whose version, providers + and output schema all move without dsoxlab knowing. The harness targets what + `build_inventory` **does** with the document, not the `json.loads` before it: + that one is already guarded, and fuzzing it would only measure the standard + library. + + The CI job's header comment now lists the covered inputs and says, for each, + why it is considered untrusted. + ## [0.1.63] - 2026-08-24 - **The documentation described a product that does not exist.** Three claims diff --git a/fuzz/corpus/context_json/champ-inconnu.json b/fuzz/corpus/context_json/champ-inconnu.json new file mode 100644 index 0000000..f74e9d6 --- /dev/null +++ b/fuzz/corpus/context_json/champ-inconnu.json @@ -0,0 +1 @@ +{"section": "linux", "futur": {"a": 1}} diff --git a/fuzz/corpus/context_json/complet.json b/fuzz/corpus/context_json/complet.json new file mode 100644 index 0000000..bfd7365 --- /dev/null +++ b/fuzz/corpus/context_json/complet.json @@ -0,0 +1 @@ +{"section": "linux", "level": "l1", "lang": "fr", "active_lab": "l1-permissions", "active_target": "rhel", "active_provider": "kvm", "course_pos": 3} diff --git a/fuzz/corpus/context_json/position-negative.json b/fuzz/corpus/context_json/position-negative.json new file mode 100644 index 0000000..6c49dc9 --- /dev/null +++ b/fuzz/corpus/context_json/position-negative.json @@ -0,0 +1 @@ +{"course_pos": -5} diff --git a/fuzz/corpus/context_json/position-non-numerique.json b/fuzz/corpus/context_json/position-non-numerique.json new file mode 100644 index 0000000..3b0ca56 --- /dev/null +++ b/fuzz/corpus/context_json/position-non-numerique.json @@ -0,0 +1 @@ +{"course_pos": "trois"} diff --git a/fuzz/corpus/context_json/racine-chaine.json b/fuzz/corpus/context_json/racine-chaine.json new file mode 100644 index 0000000..c67b31e --- /dev/null +++ b/fuzz/corpus/context_json/racine-chaine.json @@ -0,0 +1 @@ +"texte" diff --git a/fuzz/corpus/context_json/racine-liste.json b/fuzz/corpus/context_json/racine-liste.json new file mode 100644 index 0000000..b5d8bb5 --- /dev/null +++ b/fuzz/corpus/context_json/racine-liste.json @@ -0,0 +1 @@ +[1, 2, 3] diff --git a/fuzz/corpus/context_json/racine-nulle.json b/fuzz/corpus/context_json/racine-nulle.json new file mode 100644 index 0000000..19765bd --- /dev/null +++ b/fuzz/corpus/context_json/racine-nulle.json @@ -0,0 +1 @@ +null diff --git a/fuzz/corpus/context_json/section-liste.json b/fuzz/corpus/context_json/section-liste.json new file mode 100644 index 0000000..2b97841 --- /dev/null +++ b/fuzz/corpus/context_json/section-liste.json @@ -0,0 +1 @@ +{"section": ["linux"]} diff --git a/fuzz/corpus/context_json/tronque.json b/fuzz/corpus/context_json/tronque.json new file mode 100644 index 0000000..4529999 --- /dev/null +++ b/fuzz/corpus/context_json/tronque.json @@ -0,0 +1 @@ +{"section": "lin \ No newline at end of file diff --git a/fuzz/corpus/context_json/vide.json b/fuzz/corpus/context_json/vide.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/fuzz/corpus/context_json/vide.json @@ -0,0 +1 @@ +{} diff --git a/fuzz/corpus/terraform_outputs/avec-bastion.json b/fuzz/corpus/terraform_outputs/avec-bastion.json new file mode 100644 index 0000000..608fcc0 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/avec-bastion.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": "10.99.0.11"}}, "bastion": {"value": {"public_ip": "203.0.113.7", "fqdn": "bastion.exemple", "user": "outscale"}}} diff --git a/fuzz/corpus/terraform_outputs/bastion-sans-ip.json b/fuzz/corpus/terraform_outputs/bastion-sans-ip.json new file mode 100644 index 0000000..2796fbe --- /dev/null +++ b/fuzz/corpus/terraform_outputs/bastion-sans-ip.json @@ -0,0 +1 @@ +{"bastion": {"value": {"fqdn": "b.exemple"}}} diff --git a/fuzz/corpus/terraform_outputs/hosts-liste.json b/fuzz/corpus/terraform_outputs/hosts-liste.json new file mode 100644 index 0000000..a1f0540 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/hosts-liste.json @@ -0,0 +1 @@ +{"hosts": {"value": ["10.99.0.11"]}} diff --git a/fuzz/corpus/terraform_outputs/ip-numerique.json b/fuzz/corpus/terraform_outputs/ip-numerique.json new file mode 100644 index 0000000..62a6185 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/ip-numerique.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": 167837707}}} diff --git a/fuzz/corpus/terraform_outputs/outputs-aplatis.json b/fuzz/corpus/terraform_outputs/outputs-aplatis.json new file mode 100644 index 0000000..567a7ed --- /dev/null +++ b/fuzz/corpus/terraform_outputs/outputs-aplatis.json @@ -0,0 +1 @@ +{"hosts": {"un.lab": "10.99.0.11"}} diff --git a/fuzz/corpus/terraform_outputs/outputs-encapsules.json b/fuzz/corpus/terraform_outputs/outputs-encapsules.json new file mode 100644 index 0000000..b408215 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/outputs-encapsules.json @@ -0,0 +1 @@ +{"hosts": {"value": {"un.lab": "10.99.0.11", "deux.lab": "10.99.0.12"}, "type": ["object", {"un.lab": "string", "deux.lab": "string"}]}} diff --git a/fuzz/corpus/terraform_outputs/sans-hosts.json b/fuzz/corpus/terraform_outputs/sans-hosts.json new file mode 100644 index 0000000..0b175b2 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/sans-hosts.json @@ -0,0 +1 @@ +{"autre_chose": {"value": 42}} diff --git a/fuzz/corpus/terraform_outputs/value-non-objet.json b/fuzz/corpus/terraform_outputs/value-non-objet.json new file mode 100644 index 0000000..281b538 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/value-non-objet.json @@ -0,0 +1 @@ +{"hosts": {"value": "10.99.0.11"}} diff --git a/fuzz/corpus/terraform_outputs/vide.json b/fuzz/corpus/terraform_outputs/vide.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/fuzz/corpus/terraform_outputs/vide.json @@ -0,0 +1 @@ +{} diff --git a/fuzz/fuzz_context_json.py b/fuzz/fuzz_context_json.py new file mode 100755 index 0000000..836322e --- /dev/null +++ b/fuzz/fuzz_context_json.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fuzz harness for the local session context. + +`.dsoxlab-context.json` is untrusted for a different reason than `lab.yaml` and +`meta.yml`: it does not come from a lab-provider repository, it comes from the +learner's own disk. It is written by `dsoxlab use`, edited by hand when someone +is curious, truncated when a laptop is closed mid-write, and left behind by an +older version of the tool. + +**This harness has no contract exception, and that is the point.** +`read_context` promises to return an empty context whenever the file is absent, +unreadable or malformed: losing the context costs the learner one `dsoxlab use`, +where an exception costs the whole CLI, without even naming the file to delete. +So any exception at all is a failure here, unlike the YAML harnesses where +`ValueError` is the documented rejection signal. + +Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py): + mkdir -p /tmp/fuzz-context + uv run --group fuzz python fuzz/fuzz_context_json.py \ + /tmp/fuzz-context fuzz/corpus/context_json/ -atheris_runs=50000 + +See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and +why the seed corpus is what makes this harness effective. +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import atheris + +with atheris.instrument_imports(include=["dsoxlab"]): + from dsoxlab.config import read_context + +_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-context-")) + + +def test_one_input(data: bytes) -> None: + """Feed one fuzzer-generated document to read_context.""" + # Bytes are written raw, without decoding first: a context file of arbitrary + # bytes is exactly the case that used to slip through, because + # UnicodeDecodeError descends from ValueError and not from OSError. + try: + (_TMPDIR / ".dsoxlab-context.json").write_bytes(data) + except OSError: + return + + # No `except` for a contract: read_context must not raise, ever. + read_context(_TMPDIR) + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/fuzz/fuzz_terraform_outputs.py b/fuzz/fuzz_terraform_outputs.py new file mode 100755 index 0000000..54f0bab --- /dev/null +++ b/fuzz/fuzz_terraform_outputs.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Fuzz harness for the Terraform outputs the inventory is built from. + +Terraform outputs are untrusted for a third reason again: they are produced by +an **external binary** whose version, providers and output schema all move +without dsoxlab knowing. `terraform output -json` wraps every value in +`{"name": {"value": …, "type": …}}`, but a state written by another version, a +provider that renamed an output, or a hand-edited state all reach the same +reader. + +**The target is the consumption, not the parsing.** `read_terraform_outputs` +does I/O and one `json.loads` whose failure is already caught; fuzzing it would +mostly measure `json.loads`. What reaches the user as a traceback is what +`build_inventory` does with the decoded document, so that is what is fuzzed +here, with a `meta.yml` describing two hosts and a target, i.e. the shape a +`runtime: vm` lab actually goes through. + +Contract: `build_inventory` may reject a document it cannot honour, but only by +the exceptions listed below. Anything else is a crash for the learner, at the +moment they run a lab, with no clue that the cause is a stale Terraform state. + +Run it — scratch dir FIRST, seed corpus second (see fuzz_lab_yaml.py): + mkdir -p /tmp/fuzz-tfout + uv run --group fuzz python fuzz/fuzz_terraform_outputs.py \ + /tmp/fuzz-tfout fuzz/corpus/terraform_outputs/ -atheris_runs=50000 + +See fuzz/fuzz_lab_yaml.py for why instrumentation is scoped to dsoxlab only and +why the seed corpus is what makes this harness effective. +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +import atheris + +with atheris.instrument_imports(include=["dsoxlab"]): + from dsoxlab.infra.inventory import InfraNotProvisioned, build_inventory + from dsoxlab.models.repo import RepoMetadata + +#: Rejeter un document qu'on ne peut pas honorer est légitime ; le faire par une +#: exception que personne n'attend ne l'est pas. +#: +#: `InfraNotProvisioned` en fait partie, et le harnais l'a appris à sa première +#: exécution, sur la graine `{}` : un document sans adresse est le cas NORMAL du +#: premier lancement ou de l'après-`destroy`, et la CLI le rend en une phrase. +#: Une exception dédiée pour un état attendu, c'est exactement le patron que ce +#: dépôt applique, et un harnais qui l'aurait comptée comme un crash aurait +#: réclamé de le défaire. +CONTRACT_EXCEPTIONS = (KeyError, ValueError, InfraNotProvisioned) + +_TMPDIR = Path(tempfile.mkdtemp(prefix="dsoxlab-fuzz-tfout-")) +_META_YML = _TMPDIR / "meta.yml" +_META_YML.write_text( + """\ +repo: + id: fuzz + category: demo +infra: + provider: kvm + network: fuzz-net + cidr: 10.99.0.0/24 + hosts: + - name: un.lab + distro: alma10 + - name: deux.lab + distro: debian13 +""", + encoding="utf-8", +) +_REPO_META = RepoMetadata.from_yaml(_META_YML) + + +def test_one_input(data: bytes) -> None: + """Feed one fuzzer-generated outputs document to build_inventory.""" + # Raw decode, not FuzzedDataProvider — see fuzz_lab_yaml.py: the provider + # would reinterpret the seed files and make the corpus inert. + try: + document = data.decode("utf-8") + except UnicodeDecodeError: + return + + try: + outputs = json.loads(document) + except (json.JSONDecodeError, RecursionError): + return + if not isinstance(outputs, dict): + # `read_terraform_outputs` annotates its result as a mapping, and every + # caller obtains it from there. A non-mapping is out of scope for this + # harness, not a defect it should report. + return + + try: + build_inventory( + _REPO_META, + terraform_outputs=outputs, + target_fqdn="un.lab", + roles={"server": "deux.lab"}, + ) + except CONTRACT_EXCEPTIONS: + return + except RecursionError: + raise + + +def main() -> None: + atheris.Setup(sys.argv, test_one_input) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 11c481c..5369603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.63" +version = "0.1.64" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dsoxlab/infra/inventory.py b/src/dsoxlab/infra/inventory.py index 6d2ac8d..f80b216 100644 --- a/src/dsoxlab/infra/inventory.py +++ b/src/dsoxlab/infra/inventory.py @@ -85,9 +85,16 @@ def build_inventory( # ou directement {"fqdn1": "ip1"} selon que c'est `terraform output -json`. raw = terraform_outputs.get("hosts") if isinstance(raw, dict): - tf_hosts = { - k: str(v) for k, v in (raw.get("value", raw)).items() - } + # `raw.get("value", raw)` peut rendre autre chose qu'un mapping : + # un output d'une autre version de Terraform, un provider qui a + # renommé sa sortie, un state édité à la main. `.items()` levait + # alors un AttributeError, c'est-à-dire un traceback au moment de + # jouer un lab, sans dire que la cause est un state périmé. + # Trouvé par fuzz/fuzz_terraform_outputs.py sur + # `{"hosts": {"value": "10.99.0.11"}}`. + valeurs = raw.get("value", raw) + if isinstance(valeurs, dict): + tf_hosts = {k: str(v) for k, v in valeurs.items()} # Bastion : extrait via bastion_info() pour bénéficier de la # priorité meta.yml > output Terraform sur le user (cf. diff --git a/tests/test_outputs_terraform.py b/tests/test_outputs_terraform.py new file mode 100644 index 0000000..5c128e2 --- /dev/null +++ b/tests/test_outputs_terraform.py @@ -0,0 +1,97 @@ +"""Les outputs de Terraform sont une entrée que dsoxlab ne produit pas (#71). + +Ils viennent d'un binaire externe dont la version, les providers et le schéma de +sortie bougent sans que l'outil le sache. `terraform output -json` encapsule +chaque valeur dans ``{"nom": {"value": …, "type": …}}``, mais un state écrit par +une autre version, un provider qui a renommé sa sortie, ou un state édité à la +main arrivent chez le même lecteur. + +Le défaut ci-dessous a été trouvé par `fuzz/fuzz_terraform_outputs.py`, sur un +document de trente-quatre octets. Ce test le fige : le fuzzing découvre, un test +empêche le retour. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dsoxlab.infra.inventory import InfraNotProvisioned, build_inventory +from dsoxlab.models.repo import RepoMetadata + +META = """\ +repo: + id: essai + category: demo +infra: + provider: kvm + network: essai-net + cidr: 10.99.0.0/24 + hosts: + - name: un.lab + distro: alma10 +""" + + +@pytest.fixture +def repo_meta(tmp_path: Path) -> RepoMetadata: + chemin = tmp_path / "meta.yml" + chemin.write_text(META, encoding="utf-8") + return RepoMetadata.from_yaml(chemin) + + +def test_un_output_hosts_qui_n_est_pas_un_objet_ne_leve_pas(repo_meta: RepoMetadata) -> None: + """`{"hosts": {"value": "10.99.0.11"}}` faisait `AttributeError`. + + Le code prenait `raw.get("value", raw)` pour un mapping et appelait + `.items()` dessus. Le résultat était un traceback au moment de jouer un lab, + sans jamais dire que la cause était un state Terraform périmé. + + Ce qui est attendu à la place n'est pas « ça marche » : c'est la phrase que + la CLI sait déjà rendre quand aucun hôte n'a d'adresse. + """ + with pytest.raises(InfraNotProvisioned): + build_inventory(repo_meta, terraform_outputs={"hosts": {"value": "10.99.0.11"}}) + + +@pytest.mark.parametrize( + ("nom", "outputs"), + [ + ("value en liste", {"hosts": {"value": ["10.99.0.11"]}}), + ("value nulle", {"hosts": {"value": None}}), + ("value numérique", {"hosts": {"value": 167837707}}), + ("hosts en liste", {"hosts": ["10.99.0.11"]}), + ("hosts en chaîne", {"hosts": "10.99.0.11"}), + ("sortie d'une autre version", {"autre_chose": {"value": 42}}), + ], + ids=lambda v: v if isinstance(v, str) else "", +) +def test_aucune_forme_d_output_ne_rend_un_traceback( + repo_meta: RepoMetadata, nom: str, outputs: dict[str, object] +) -> None: + """Toutes ces formes sont plausibles, aucune ne doit surprendre l'appelant. + + Le contrat n'est pas que dsoxlab les honore : c'est qu'il les refuse par une + exception que la CLI sait rendre en une phrase. + """ + with pytest.raises(InfraNotProvisioned): + build_inventory(repo_meta, terraform_outputs=outputs) + + +def test_la_forme_normale_reste_lue(repo_meta: RepoMetadata) -> None: + """Le contre-test : sans lui, refuser TOUT passerait pour une correction.""" + inventaire = build_inventory( + repo_meta, terraform_outputs={"hosts": {"value": {"un.lab": "10.99.0.11"}}} + ) + + hotes = inventaire["all"]["children"]["labenv"]["hosts"] + assert hotes["un.lab"]["ansible_host"] == "10.99.0.11" + + +def test_la_forme_aplatie_reste_lue(repo_meta: RepoMetadata) -> None: + """L'autre forme acceptée, selon d'où viennent les outputs.""" + inventaire = build_inventory(repo_meta, terraform_outputs={"hosts": {"un.lab": "10.99.0.11"}}) + + hotes = inventaire["all"]["children"]["labenv"]["hosts"] + assert hotes["un.lab"]["ansible_host"] == "10.99.0.11" diff --git a/uv.lock b/uv.lock index 702bbfb..c1f1a3d 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.63" +version = "0.1.64" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },