erplibre/script/qemu_cache/oublie_test.go
Mathieu Benoit e0624d9aaa [ADD] cache : retirer une seule entrée du magasin, par URL
Rien n'invalidait une entrée dont la somme ne correspond pas : le magasin
continuait de la servir, et retélécharger ne changeait rien puisque c'est
lui qui répond. Les deux purges existantes ne l'atteignent pas — l'une
efface tout, l'autre saute ce qui est récent alors que chaque service
remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit
jamais.

Symétrique de « --detient » : mêmes lignes, mêmes fonctions de clé, mêmes
refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié.
Mesuré : garde, puis oublié 53080, puis absent.

--- EN ---

Nothing invalidated an entry whose checksum does not match: the store kept
serving it, and re-downloading changed nothing since the store is what
answers. Neither existing purge reaches it — one erases everything, the
other skips what is recent while every service resets that date, so a
poisoned object that serves never ages.

Symmetric to "--detient": same lines, same key functions, same refusals,
and a present object that resists is reported refused rather than
forgotten. Measured: held, then forgotten 53080, then absent.

Assisted-by: Claude Opus 5
2026-09-16 21:32:03 -04:00

188 lines
6.3 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// Le magasin est rempli PAR le service, comme pour « --detient » : c'est la
// seule façon de voir une divergence entre le calcul de clé du service et
// celui de la commande. Effacer sous une clé que le service n'écrit pas
// laisserait l'objet empoisonné en place tout en disant l'avoir retiré.
func TestOublieRetireCeQueLeMagasinSert(t *testing.T) {
paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst"
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/installer.sh":
rediriger(http.StatusFound,
"https://ailleurs.example/bootstrap.sh")(w, r)
default:
servir("contenu du paquet")(w, r)
}
})
p := proxyDeTest(t)
for _, c := range []string{"/installer.sh", "/depot/etat", paquet} {
joue(t, p, "GET", a.hote(), c)
}
base := "http://" + a.hote()
questions := []string{
"GET " + base + paquet,
"GET " + base + "/installer.sh",
"GET " + base + "/jamais-vu.sh",
"POST " + base + "/depot/etat",
"",
"# un commentaire",
"GET pas-une-url",
}
var sortie bytes.Buffer
if err := EcrireOublis(
p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie,
); err != nil {
t.Fatal(err)
}
attendus := []struct{ verdict, classe, methode, url string }{
{"oublié", "immutable", "GET", base + paquet},
// Une redirection est un STATUT SEUL : sans la seconde clé, le rejeu
// la resservirait vers l'octet qu'on vient de retirer.
{"oublié", "volatile", "GET", base + "/installer.sh"},
{"absent", "volatile", "GET", base + "/jamais-vu.sh"},
{"non-cachable", "volatile", "POST", base + "/depot/etat"},
{"non-cachable", "-", "GET", "pas-une-url"},
}
lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n")
if len(lignes) != len(attendus) {
t.Fatalf("%d lignes, attendu %d :\n%s",
len(lignes), len(attendus), sortie.String())
}
for i, at := range attendus {
champs := strings.Split(lignes[i], "\t")
if len(champs) != 5 {
t.Errorf("ligne %d : %d champs, attendu 5 : %q",
i+1, len(champs), lignes[i])
continue
}
if champs[0] != at.verdict || champs[2] != at.classe ||
champs[3] != at.methode || champs[4] != at.url {
t.Errorf("ligne %d : %q, attendu %s … %s %s %s",
i+1, lignes[i], at.verdict, at.classe, at.methode, at.url)
}
}
// Les octets ne sont comptés que pour ce qui est parti.
if champs := strings.Split(lignes[0], "\t"); champs[1] == "-" {
t.Error("un objet effacé doit dire les octets rendus")
}
if champs := strings.Split(lignes[2], "\t"); champs[1] != "-" {
t.Errorf("rien d'effacé, octets %q", champs[1])
}
}
// L'INVARIANT qui rend la commande sûre : ce que « --detient » dit tenir,
// « --oublie » l'efface, et « --detient » ne le tient plus. Deux calculs de
// clé qui divergeraient se verraient ici, et nulle part ailleurs.
func TestCeQueDetientVoitOublieLeRetire(t *testing.T) {
paquet := "/core/os/x86_64/outil-2.0-1-x86_64.pkg.tar.zst"
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
joue(t, p, "GET", a.hote(), paquet)
question := "GET http://" + a.hote() + paquet
avant := ligneUnique(t, p, question, EcrireDetentions)
if verdict := strings.Split(avant, "\t")[0]; verdict != "garde" {
t.Fatalf("le magasin devrait garder : %q", avant)
}
efface := ligneUnique(t, p, question, EcrireOublis)
if verdict := strings.Split(efface, "\t")[0]; verdict != "oublié" {
t.Fatalf("l'oubli devrait effacer : %q", efface)
}
apres := ligneUnique(t, p, question, EcrireDetentions)
if verdict := strings.Split(apres, "\t")[0]; verdict != "absent" {
t.Fatalf("le magasin tient encore : %q", apres)
}
}
// Ce que ni « --purge » ni « --purge-older-than » ne savent faire : retirer
// UNE entrée. La voisine reste, et c'est tout l'intérêt — un objet empoisonné
// ne doit pas coûter le cache entier.
func TestOublieNeTouchePasLaVoisine(t *testing.T) {
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
garde := "/core/os/x86_64/garde-1.0-1-x86_64.pkg.tar.zst"
part := "/core/os/x86_64/part-1.0-1-x86_64.pkg.tar.zst"
joue(t, p, "GET", a.hote(), garde)
joue(t, p, "GET", a.hote(), part)
ligneUnique(t, p, "GET http://"+a.hote()+part, EcrireOublis)
reste := ligneUnique(t, p, "GET http://"+a.hote()+garde, EcrireDetentions)
if verdict := strings.Split(reste, "\t")[0]; verdict != "garde" {
t.Fatalf("la voisine a disparu : %q", reste)
}
}
// Un objet présent qui résiste est un REFUS, jamais un oubli : croire un
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
func TestUnObjetQuiResisteEstUnRefus(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root efface dans un répertoire en lecture seule")
}
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
paquet := "/core/os/x86_64/dur-1.0-1-x86_64.pkg.tar.zst"
joue(t, p, "GET", a.hote(), paquet)
u := "http://" + a.hote() + paquet
casier := casierDe(t, p, u)
fige(t, casier, 0o500)
ligne := ligneUnique(t, p, "GET "+u, EcrireOublis)
if verdict := strings.Split(ligne, "\t")[0]; verdict != "refus" {
t.Fatalf("un effacement impossible doit se dire : %q", ligne)
}
}
// ligneUnique pose UNE question et rend la ligne rendue, sans son saut.
func ligneUnique(
t *testing.T, p *Proxy, question string,
ecrire func(*Store, io.Reader, io.Writer) error,
) string {
t.Helper()
var sortie bytes.Buffer
if err := ecrire(p.Store, strings.NewReader(question), &sortie); err != nil {
t.Fatal(err)
}
return strings.TrimRight(sortie.String(), "\n")
}
// casierDe rend le répertoire où le magasin range le corps d'une URL.
func casierDe(t *testing.T, p *Proxy, brut string) string {
t.Helper()
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
_, corps := p.Store.paths(CleDe("GET", u))
return filepath.Dir(corps)
}
// fige retire le droit d'écrire dans un répertoire, et le rend au nettoyage :
// sans cela le répertoire temporaire du test ne pourrait plus être effacé.
func fige(t *testing.T, dir string, mode os.FileMode) {
t.Helper()
avant, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, mode); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, avant.Mode()) })
}