La règle de clé d'un fichier portable a changé : seule la fin du chemin compte désormais. Les objets écrits sous l'ancienne règle restent sur le disque mais deviennent introuvables — le service les redemande à l'amont, et la place qu'ils tiennent ne sert plus personne. Sur un magasin de 12 764 objets, 5 419 étaient dans ce cas, soit 9,11 Gio. La passe les range sous la clé courante sans rien retélécharger, et fond au passage les copies qu'un miroir portait sous plusieurs chemins — 1 422 groupes, environ 3,37 Gio rendus. Un statut seul n'est pas touché : sa clé porte l'hôte. Vérifié : cinq tests, trois mutations attrapées. --- EN --- The key rule for a portable file changed: only the end of the path now counts. Objects written under the old rule stay on disk but become unreachable — the service asks upstream again, and the space they hold serves no one. On a store of 12 764 objects, 5 419 were in that case, that is 9.11 GiB. The pass stores them again under the current key without downloading anything, and merges on the way the copies a mirror carried under several paths — 1 422 groups, about 3.37 GiB returned. A status-only entry is left alone: its key carries the host. Checked: five tests, three mutations caught. Assisted-by: Claude Opus 5
231 lines
6.9 KiB
Go
231 lines
6.9 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ancienneCle reproduit la règle d'AVANT : le chemin entier, tel que le
|
|
// magasin le hachait jusqu'à la normalisation. Les objets déjà rangés sur un
|
|
// disque portent cette clé-là, et c'est d'eux que la passe s'occupe.
|
|
func ancienneCle(methode string, u *url.URL) string {
|
|
sum := sha256.Sum256([]byte(methode + " path " + u.Path))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// planter écrit un objet sous la clé donnée, comme le magasin l'aurait fait.
|
|
func planter(t *testing.T, s *Store, cle, brut, corps string) {
|
|
t.Helper()
|
|
w, err := s.NewWriter(cle, Meta{
|
|
URL: brut, Method: "GET", Status: 200, Class: "immutable",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("écriture de %s : %v", brut, err)
|
|
}
|
|
if _, err := w.Write([]byte(corps)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Commit(int64(len(corps))); err != nil {
|
|
t.Fatalf("publication de %s : %v", brut, err)
|
|
}
|
|
}
|
|
|
|
const (
|
|
rockyA = "https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
|
|
rockyB = "https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
|
|
)
|
|
|
|
// Un objet rangé sous l'ancienne clé est introuvable : le service le
|
|
// redemande à l'amont alors qu'il est sur le disque. La passe le range sous la
|
|
// clé courante, sans rien retélécharger.
|
|
func TestReclerRendTrouvableUnObjetAncien(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
u, _ := url.Parse(rockyA)
|
|
planter(t, s, ancienneCle("GET", u), rockyA, "le paquet")
|
|
|
|
if s.Detient(CleDe("GET", u)) {
|
|
t.Fatal("l'objet est déjà trouvable : le test ne mesure rien")
|
|
}
|
|
bilan, err := s.Recler(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Deplaces != 1 || bilan.Fondus != 0 {
|
|
t.Errorf("bilan %+v, attendu un seul déplacement", bilan)
|
|
}
|
|
if !s.Detient(CleDe("GET", u)) {
|
|
t.Error("l'objet reste introuvable après la passe")
|
|
}
|
|
m, f, err := s.Get(CleDe("GET", u))
|
|
if err != nil {
|
|
t.Fatalf("lecture après passe : %v", err)
|
|
}
|
|
defer f.Close()
|
|
if m.URL != rockyA {
|
|
t.Errorf("méta perdu en route : %q", m.URL)
|
|
}
|
|
}
|
|
|
|
// Deux chemins de miroir pour le même fichier : la clé courante les réunit, et
|
|
// la passe n'en garde qu'un — le plus récemment rangé.
|
|
//
|
|
// Les deux DIRECTIONS sont éprouvées, et l'ordre est fixé en plantant d'abord
|
|
// l'objet de destination : laisser le parcours décider laquelle des deux
|
|
// copies arrive la première ne couvrait qu'une branche sur deux, au gré du
|
|
// hachage, et la branche non couverte est passée au travers d'une mutation.
|
|
func TestReclerFondLesCopiesDeMiroir(t *testing.T) {
|
|
ua, _ := url.Parse(rockyA)
|
|
|
|
t.Run("entrante plus ancienne", func(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
planter(t, s, ancienneCle("GET", ua), rockyA, "copie ancienne")
|
|
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, plus recente")
|
|
|
|
bilan, err := s.Recler(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Fondus != 1 || bilan.OctetsRendus == 0 {
|
|
t.Errorf("bilan %+v, attendu une fusion qui rend des octets", bilan)
|
|
}
|
|
if corps := corpsDuMagasin(t, s); corps != 1 {
|
|
t.Errorf("%d corps sur le disque, attendu 1", corps)
|
|
}
|
|
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie en place, plus recente" {
|
|
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
|
|
}
|
|
})
|
|
|
|
t.Run("entrante plus récente", func(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, ancienne")
|
|
planter(t, s, ancienneCle("GET", ua), rockyA, "copie entrante, recente")
|
|
|
|
bilan, err := s.Recler(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Fondus != 1 || bilan.Deplaces != 1 {
|
|
t.Errorf("bilan %+v, attendu une fusion et un déplacement", bilan)
|
|
}
|
|
if corps := corpsDuMagasin(t, s); corps != 1 {
|
|
t.Errorf("%d corps sur le disque, attendu 1", corps)
|
|
}
|
|
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie entrante, recente" {
|
|
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
|
|
}
|
|
})
|
|
}
|
|
|
|
// corpsDuMagasin compte les corps réellement sur le disque : une copie qui
|
|
// n'est plus référencée mais reste écrite occupe la place qu'on croyait rendue.
|
|
func corpsDuMagasin(t *testing.T, s *Store) int {
|
|
t.Helper()
|
|
n := 0
|
|
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
|
|
if err == nil && info != nil && !info.IsDir() &&
|
|
strings.HasSuffix(p, ".body") {
|
|
n++
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func lireCorps(t *testing.T, s *Store, cle string) string {
|
|
t.Helper()
|
|
_, f, err := s.Get(cle)
|
|
if err != nil {
|
|
t.Fatalf("lecture de %s : %v", cle, err)
|
|
}
|
|
defer f.Close()
|
|
b, err := io.ReadAll(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Un chemin court tient tout entier dans la clé : rien ne doit bouger.
|
|
func TestReclerNeTouchePasUnCheminCourt(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
brut := "https://a.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst"
|
|
u, _ := url.Parse(brut)
|
|
planter(t, s, CleDe("GET", u), brut, "paquet arch")
|
|
|
|
bilan, err := s.Recler(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Deplaces != 0 || bilan.Inchanges != 1 {
|
|
t.Errorf("bilan %+v, attendu un objet inchangé", bilan)
|
|
}
|
|
if !s.Detient(CleDe("GET", u)) {
|
|
t.Error("un objet qui ne devait pas bouger a disparu")
|
|
}
|
|
}
|
|
|
|
// Un statut seul est rangé sous une clé qui porte l'HÔTE : la règle du chemin
|
|
// ne le concerne pas, et le déplacer l'enverrait là où personne ne le lit.
|
|
func TestReclerLaisseLesStatutsSeuls(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
brut := "https://a.example/depot/releases/latest/download/outil.tar.gz"
|
|
u, _ := url.Parse(brut)
|
|
cle := CleStatut("GET", u)
|
|
w, err := s.NewWriter(cle, Meta{
|
|
URL: brut, Method: "GET", Status: 302, Class: "volatile",
|
|
StatusOnly: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Commit(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
bilan, err := s.Recler(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Deplaces != 0 {
|
|
t.Errorf("bilan %+v : un statut seul a été déplacé", bilan)
|
|
}
|
|
if _, err := s.LireMeta(cle); err != nil {
|
|
t.Error("le statut seul n'est plus sous sa clé")
|
|
}
|
|
}
|
|
|
|
// À blanc, la passe compte sans rien écrire : c'est ce qui permet de la lancer
|
|
// avant de décider.
|
|
func TestReclerABlancNeDeplaceRien(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
u, _ := url.Parse(rockyA)
|
|
ancienne := ancienneCle("GET", u)
|
|
planter(t, s, ancienne, rockyA, "le paquet")
|
|
|
|
bilan, err := s.Recler(true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bilan.Deplaces != 1 {
|
|
t.Errorf("bilan %+v, attendu un déplacement annoncé", bilan)
|
|
}
|
|
if _, err := s.LireMeta(ancienne); err != nil {
|
|
t.Error("l'objet a bougé alors que la passe était à blanc")
|
|
}
|
|
if s.Detient(CleDe("GET", u)) {
|
|
t.Error("un objet est apparu sous la clé courante, à blanc")
|
|
}
|
|
}
|