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
188 lines
6.3 KiB
Go
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()) })
|
|
}
|