erplibre/script/qemu_cache/classify_test.go

421 lines
16 KiB
Go
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"net/url"
"strings"
"testing"
)
func TestClassify(t *testing.T) {
cas := []struct {
brut string
attendue Class
pourquoi string
}{
// Le nom porte la version : le contenu ne changera jamais.
{"https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
ClassImmutable, "paquet Arch"},
{"https://miroir.example/debian/pool/main/b/bash/bash_5.2-1_amd64.deb",
ClassImmutable, "paquet Debian"},
{"https://miroir.example/fedora/Packages/b/bash-5.2-1.fc43.x86_64.rpm",
ClassImmutable, "paquet Fedora"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl",
ClassImmutable, "roue Python"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata",
ClassImmutable, "métadonnées d'une roue (PEP 658)"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0.tar.gz.metadata",
ClassImmutable, "métadonnées d'une archive source (PEP 658)"},
{"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2",
ClassImmutable, "image cloud"},
// L'index décrit l'état COURANT du miroir.
{"https://miroir.example/arch/core/os/x86_64/core.db",
ClassVolatile, "base Arch"},
{"https://miroir.example/arch/core/os/x86_64/core.db.sig",
ClassVolatile, "signature de la base"},
{"https://miroir.example/debian/dists/trixie/InRelease",
ClassVolatile, "index Debian"},
{"https://miroir.example/debian/dists/trixie/main/binary-amd64/Packages.gz",
ClassVolatile, "liste Debian"},
{"https://miroir.example/fedora/repodata/repomd.xml",
ClassVolatile, "index Fedora"},
{"https://pypi.example/simple/requests/",
ClassVolatile, "index simple de PyPI"},
// Le doute profite au volatile : une requête paramétrée décrit un
// appel d'API, pas un fichier.
{"https://api.example/v1/paquet?nom=bash&version=5.2",
ClassVolatile, "chaîne de requête"},
{"https://exemple.example/page-quelconque",
ClassVolatile, "inconnu"},
}
for _, c := range cas {
u, err := url.Parse(c.brut)
if err != nil {
t.Fatalf("URL de test invalide %q : %v", c.brut, err)
}
if got := Classify(u); got != c.attendue {
t.Errorf("%s : %s classé « %s », attendu « %s »",
c.pourquoi, c.brut, got, c.attendue)
}
}
}
// Une extension immuable portée par une chaîne de requête ne suffit pas : le
// paramètre peut changer la réponse.
func TestChaineDeRequeteEmportreSurExtension(t *testing.T) {
u, _ := url.Parse("https://m.example/bash-5.2-1-x86_64.pkg.tar.zst?mirror=2")
if got := Classify(u); got != ClassVolatile {
t.Errorf("classé « %s », attendu « volatile »", got)
}
}
func TestCacheableMethod(t *testing.T) {
for _, m := range []string{"GET", "HEAD"} {
if !CacheableMethod(m) {
t.Errorf("%s devrait être cachable", m)
}
}
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "CONNECT"} {
if CacheableMethod(m) {
t.Errorf("%s ne doit pas être cachable", m)
}
}
}
func TestClassifyURLNulle(t *testing.T) {
if got := Classify(nil); got != ClassNoStore {
t.Errorf("une URL absente vaut « %s », attendu « no-store »", got)
}
}
// Un même fichier, deux miroirs : le cache doit le reconnaître.
//
// Une liste de miroirs tourne, et pacman a réellement tiré « extra.db » de
// « geo » alors que le cache ne détenait que la copie de « fastly » — même
// fichier, autre nom d'hôte, défaut de cache et 504 hors ligne.
func TestMemeCheminSurDeuxMiroirs(t *testing.T) {
a, _ := url.Parse("https://fastly.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
b, _ := url.Parse("https://geo.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
if !PortableParChemin(a) {
t.Fatal("un paquet n'est pas reconnu portable")
}
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Error("deux miroirs du même fichier donnent deux clés")
}
if Key("GET", a.String()) == Key("GET", b.String()) {
t.Error("la clé complète devrait, elle, distinguer les deux URL")
}
}
// L'index aussi : c'est lui qui a échoué hors ligne.
func TestIndexPortableEntreMiroirs(t *testing.T) {
a, _ := url.Parse("https://fastly.example/extra/os/x86_64/extra.db")
b, _ := url.Parse("https://geo.example/extra/os/x86_64/extra.db")
if !PortableParChemin(a) {
t.Fatal("un index de dépôt n'est pas reconnu portable")
}
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Error("deux miroirs du même index donnent deux clés")
}
}
// Mais pas n'importe quoi : « /index.html » ne nomme rien, et deux sites sans
// rapport en portent un.
func TestUnePageQuelconqueNestPasPortable(t *testing.T) {
for _, brut := range []string{
"https://a.example/index.html",
"https://a.example/quelque-chose",
"https://a.example/simple/requests/",
"https://a.example/core.db?miroir=2",
} {
u, _ := url.Parse(brut)
if PortableParChemin(u) {
t.Errorf("%s est jugé portable, à tort", brut)
}
}
}
// Le protocole « smart » de git est une NÉGOCIATION : le serveur calcule sa
// réponse d'après ce que le client détient déjà. Rien n'y est réutilisable, et
// servir une réponse gardée ferait croire à des références disparues.
func TestGitSmartNestJamaisGarde(t *testing.T) {
for _, brut := range []string{
"https://git.example/org/depot.git/info/refs?service=git-upload-pack",
"https://git.example/org/depot.git/git-upload-pack",
"https://git.example/org/depot.git/git-receive-pack",
} {
u, _ := url.Parse(brut)
if !EstGitSmart(u) {
t.Errorf("%s n'est pas reconnu comme négociation git", brut)
}
if got := Classify(u); got != ClassNoStore {
t.Errorf("%s classé « %s », attendu « no-store »", brut, got)
}
}
}
// Le protocole « dumb », lui, sert des fichiers : un objet porte son empreinte
// dans son nom et se garde comme n'importe quel fichier figé.
func TestLesObjetsGitRestentCachables(t *testing.T) {
for _, brut := range []string{
"https://git.example/org/depot.git/objects/ab/cdef0123456789",
"https://git.example/org/depot.git/objects/pack/pack-abc.pack",
} {
u, _ := url.Parse(brut)
if EstGitSmart(u) {
t.Errorf("%s pris pour une négociation, à tort", brut)
}
}
}
func TestEstGitSmartURLNulle(t *testing.T) {
if EstGitSmart(nil) {
t.Error("une URL absente est prise pour du git")
}
}
// Un index Debian publié sous l'empreinte de son contenu est aussi figé
// qu'un paquet : son nom EST sa somme. Il se range donc SANS son hôte, un
// contenu différent portant forcément un autre nom.
func TestLesIndexParEmpreinteSontImmuables(t *testing.T) {
sha256 := strings.Repeat("0123456789abcdef", 4)
md5 := strings.Repeat("0123456789abcdef", 2)
for _, brut := range []string{
"https://miroir.example/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + sha256,
"https://miroir.example/debian/dists/trixie/main/i18n/by-hash/MD5Sum/" + md5,
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassImmutable {
t.Errorf("%s classé « %s », attendu « immutable »", brut, got)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas jugé portable", brut)
}
}
// Ce qui n'est pas une empreinte garde la règle d'avant.
for _, brut := range []string{
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/pas-une-somme",
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/" + sha256 + "?v=2",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
}
}
// Une métadonnée RPM nommée par sa somme est figée, quelle que soit sa
// compression ; « repomd.xml », qui la désigne, reste volatile, et un nom sans
// somme en tête garde la règle de son suffixe.
func TestLesMetadonneesRPMParEmpreinteSontImmuables(t *testing.T) {
somme := strings.Repeat("0123456789abcdef", 4)
for _, nom := range []string{
somme + "-primary.xml.zck", somme + "-primary.xml.gz",
somme + "-updateinfo.xml.zst", somme + "-comps-BaseOS.x86_64.xml",
} {
u, _ := url.Parse("https://miroir.example/fedora/linux/updates/42/Everything/x86_64/repodata/" + nom)
if got := Classify(u); got != ClassImmutable {
t.Errorf("%s classé « %s », attendu « immutable »", nom, got)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas jugé portable", nom)
}
}
for _, brut := range []string{
"https://miroir.example/fedora/repodata/repomd.xml",
"https://miroir.example/fedora/repodata/primary.xml.gz",
"https://miroir.example/fedora/repodata/pas-une-somme-primary.xml.zck",
"https://miroir.example/fedora/ailleurs/" + somme + "-primary.xml.zck",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
}
}
// Un miroir préfixe le chemin à sa guise, et le chemin entier donne alors
// deux clés pour un même octet : le cache retélécharge ce qu'il détient.
func TestLesPrefixesDeMiroirNeFontQuUneCle(t *testing.T) {
for _, cas := range [][2]string{
{
"https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm",
"https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm",
},
{
"https://a.example/pub/archive/fedora/linux/updates/42/Everything/x86_64/Packages/n/nodejs-libs-22.22.0-2.fc42.x86_64.rpm",
"https://b.example/pub/fedora-archive/fedora/linux/updates/42/Everything/x86_64/Packages/n/nodejs-libs-22.22.0-2.fc42.x86_64.rpm",
},
{
"https://a.example/pub/rocky//10.2/BaseOS/x86_64/os/Packages/a/avahi-0.9-2.el10.x86_64.rpm",
"https://a.example/pub/rocky/10.2/BaseOS/x86_64/os/Packages/a/avahi-0.9-2.el10.x86_64.rpm",
},
} {
a, _ := url.Parse(cas[0])
b, _ := url.Parse(cas[1])
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Errorf("deux chemins du même fichier donnent deux clés :\n %s\n %s",
cas[0], cas[1])
}
}
}
// Deux distributions publient le même NOM pour d'autres octets : les confondre
// servirait le paquet de l'une à l'autre. Six segments gardent ce qui les
// sépare, cinq l'effaceraient — 38 noms en collision sur le journal relevé.
func TestDeuxDistributionsNePartagentPasLaCle(t *testing.T) {
deb, _ := url.Parse("https://deb.example/debian/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
ubu, _ := url.Parse("https://ubu.example/ubuntu/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
if KeySansHote("GET", deb) == KeySansHote("GET", ubu) {
t.Error("deux distributions partagent une clé : l'une serait servie" +
" avec le paquet de l'autre")
}
}
// Un miroir d'Arch préfixe « archlinux/ », l'autre non : quatre et cinq
// segments, trop courts pour la borne commune, qui les garde entiers et en
// fait deux clés.
func TestLesDeuxFormesDUnMiroirArchNeFontQuUneCle(t *testing.T) {
for _, cas := range [][2]string{
{
"https://geo.example/archlinux/core/os/x86_64/linux-7.2.4.arch1-2-x86_64.pkg.tar.zst",
"https://fastly.example/core/os/x86_64/linux-7.2.4.arch1-2-x86_64.pkg.tar.zst",
},
{
"https://geo.example/archlinux/extra/os/x86_64/llvm-libs-22.1.8-2-x86_64.pkg.tar.zst",
"https://fastly.example/extra/os/x86_64/llvm-libs-22.1.8-2-x86_64.pkg.tar.zst",
},
} {
a, _ := url.Parse(cas[0])
b, _ := url.Parse(cas[1])
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Errorf("deux formes du même paquet donnent deux clés :\n %s\n %s",
cas[0], cas[1])
}
}
}
// La borne d'Arch s'arrête à quatre pour GARDER le nom du dépôt : trois ou
// deux passeraient sur les données relevées, mais confondraient un paquet de
// « core » avec celui de « extra » si les deux portaient un jour le même nom.
func TestLaCleDArchGardeLeNomDuDepot(t *testing.T) {
a, _ := url.Parse("https://m.example/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst")
b, _ := url.Parse("https://m.example/extra/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst")
if KeySansHote("GET", a) == KeySansHote("GET", b) {
t.Error("deux dépôts d'Arch partagent une clé : le nom du dépôt est perdu")
}
}
// La borne courte ne vaut QUE pour la famille pacman : un « .deb » garde la
// borne haute, faute de quoi le paquet de Debian et celui d'Ubuntu, qui
// portent le même nom pour d'autres octets, se confondraient.
func TestLaBorneCourteNeVautQuePourPacman(t *testing.T) {
deb, _ := url.Parse("https://deb.example/debian/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
ubu, _ := url.Parse("https://ubu.example/ubuntu/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
if KeySansHote("GET", deb) == KeySansHote("GET", ubu) {
t.Error("la borne courte a débordé sur la famille deb")
}
if !estPaquetArch("linux-7.2.4.arch1-2-x86_64.pkg.tar.zst") ||
estPaquetArch("poppler-data_0.4.12-1_all.deb") {
t.Error("la reconnaissance de la famille pacman est fausse")
}
}
// Le même index par empreinte, servi par deux miroirs. Sans clé portable,
// changer de miroir vide le cache de ses index : une installation hors ligne
// échoue alors sur des octets que le magasin détient pourtant, et le message
// accuse le dépôt.
func TestUnIndexParEmpreinteVautSurDeuxMiroirs(t *testing.T) {
somme := strings.Repeat("0123456789abcdef", 4)
chemin := "/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + somme
a, _ := url.Parse("https://miroir-a.example" + chemin)
b, _ := url.Parse("https://miroir-b.example" + chemin)
if CleDe("GET", a) != CleDe("GET", b) {
t.Error("deux miroirs du même index donnent deux clés")
}
// La clé complète doit, elle, continuer de les distinguer : c'est ce qui
// prouve que le rangement passe bien par la clé SANS hôte, et non que les
// deux fonctions se sont mises à rendre la même chose.
if Key("GET", a.String()) == Key("GET", b.String()) {
t.Error("la clé complète ne distingue plus les deux URL")
}
}
// Un pointeur vers la dernière version publiée change de cible à chaque
// publication : son suffixe d'archive ne doit pas le figer sur le disque, ni
// le ranger sans son hôte.
func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) {
for _, brut := range []string{
"https://forge.example/o/d/releases/latest/download/outil-x86_64-linux.tar.gz",
"https://forge.example/o/d/releases/latest/download/outil.zip",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
if PortableParChemin(u) {
t.Errorf("%s jugé portable", brut)
}
}
// Une version NOMMÉE reste figée par son suffixe.
u, _ := url.Parse("https://forge.example/o/d/releases/download/v1.2.3/outil.tar.gz")
if got := Classify(u); got != ClassImmutable {
t.Errorf("une version nommée classée « %s », attendu « immutable »", got)
}
}
// Les métadonnées PEP 658 se rangent sans leur hôte, comme l'archive qu'elles
// décrivent : leur chemin porte déjà l'empreinte du contenu.
func TestLesMetadonneesPythonSontPortables(t *testing.T) {
for _, brut := range []string{
"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata",
"https://pypi.example/packages/ab/cd/requests-2.33.0.zip.metadata",
} {
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas portable", brut)
}
}
}
// Les objets de nix sont adressés par l'empreinte du chemin de store : leur
// NOM est cette empreinte. Ils tombaient dans le volatil par défaut, donc
// revalidés un par un contre l'amont — une installation NixOS en demande des
// centaines, et aucun ne changera jamais.
func TestLesObjetsDeNixSontImmuables(t *testing.T) {
for _, chemin := range []string{
"/kdb6ag6vg2vfkxy3k47zqy7jm0266dhn.narinfo",
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.zst",
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.xz",
} {
u, err := url.Parse("https://cache.nixos.org" + chemin)
if err != nil {
t.Fatal(err)
}
if c := Classify(u); c != ClassImmutable {
t.Errorf("%s : classe %v, attendu immuable", chemin, c)
}
// Le nom identifie le fichier sur TOUT miroir : la clé écarte l'hôte.
if !PortableParChemin(u) {
t.Errorf("%s : devrait se ranger sans son hôte", chemin)
}
}
}
// « nix-cache-info » n'est PAS adressé par une empreinte : c'est la
// description du magasin amont, et elle peut changer. Elle reste volatile.
func TestLaDescriptionDuMagasinNixResteVolatile(t *testing.T) {
u, _ := url.Parse("https://cache.nixos.org/nix-cache-info")
if c := Classify(u); c == ClassImmutable {
t.Errorf("nix-cache-info tenu pour immuable : %v", c)
}
}