[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
This commit is contained in:
Mathieu Benoit 2026-09-16 00:43:23 -04:00
parent 64a8e6400f
commit e0624d9aaa
8 changed files with 460 additions and 6 deletions

View file

@ -986,7 +986,22 @@ def image_fetch_cmd(
# Et la cible elle-même, fraîche ou déjà en cache : le cas visé est un
# fichier substitué entre deux déploiements, qu'aucun test de présence
# ne voit.
cmd += f" && {somme(cible)}"
#
# L'échec nomme le remède, parce que le remède ORDINAIRE ne suffit
# pas : cet hôte peut être une VM derrière le cache de
# téléchargement, et effacer l'image la fera resservir à l'identique
# depuis le magasin. L'entrée s'en retire d'abord — « --purge »
# efface tout, et « --purge-older-than » n'atteint jamais un objet
# que chaque service rajeunit.
aide = (
f"rm -f {shlex.quote(cible)} et relancer ;"
" derrière un cache de téléchargement, en retirer l'entrée"
f" d'abord : printf %s | sudo erplibre_go_qemu_cache --oublie"
)
cmd += (
f" && {{ {somme(cible)} || {{ "
f"echo {shlex.quote(aide)} >&2; false; }}; }}"
)
return cmd

View file

@ -1657,7 +1657,9 @@ def pinned_sha256(distro: str) -> str:
return NIXOS_IMAGE_SHA256 if distro == "nixos" else ""
def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
def verify_pinned_sha256(
distro: str, image: Path, dry_run: bool, urls: tuple = ()
) -> None:
"""Vérifie une image contre la somme que le DÉPÔT porte pour elle.
Sans rapport avec --verify, qui lit un SHA256SUMS publié par la
@ -1678,6 +1680,20 @@ def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
digest.update(morceau)
obtenu = digest.hexdigest()
if obtenu != attendu:
# Le remède ORDINAIRE ne suffit pas derrière le cache : effacer le
# fichier le fait retélécharger, et c'est le cache qui resservira les
# mêmes octets, indéfiniment. Son entrée se retire d'abord — ni
# « --purge », qui efface tout, ni « --purge-older-than », qu'un objet
# servi ne vieillit jamais assez pour atteindre.
remede = ""
if urls:
lignes = "\n".join(f"GET {u}" for u in urls)
remede = (
"\n\n Si un cache de téléchargement est en place, effacer le "
"fichier ne suffit pas : retirez-en l'entrée d'abord.\n"
f" printf '%s\\n' {shlex.quote(lignes)} \\\n"
" | sudo erplibre_go_qemu_cache --oublie"
)
sys.exit(
f"Erreur : l'image {image} ne correspond pas à la somme que le "
f"dépôt porte pour elle.\n"
@ -1685,7 +1701,7 @@ def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
f" obtenu : {obtenu}\n"
" Supprimez le fichier pour le retélécharger. S'il revient "
"différent, l'amont a republié sous le même tag : le relire "
"avant de figer la nouvelle somme."
"avant de figer la nouvelle somme." + remede
)
print(f" Somme sha256 conforme à celle du dépôt ({attendu[:12]}…).")
@ -5156,7 +5172,9 @@ def main() -> None:
)
print(f" Destination : {args.image_path}")
download_image(urls, args.image_path, args.dry_run)
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
verify_pinned_sha256(
args.distro, args.image_path, args.dry_run, tuple(urls)
)
if do_verify:
verify_sha256(url, args.image_path, args.dry_run)
print("\nTerminé (téléchargement seul).")
@ -5271,7 +5289,9 @@ def main() -> None:
f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) =="
)
download_image(urls, args.image_path, args.dry_run)
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
verify_pinned_sha256(
args.distro, args.image_path, args.dry_run, tuple(urls)
)
if do_verify:
verify_sha256(url, args.image_path, args.dry_run)

View file

@ -74,7 +74,8 @@ var anglais = map[string]string{
"effacer le miroir de ce dépôt ; il se refera au prochain besoin": "erase this repository's mirror; it is rebuilt when next needed",
"dire les exceptions en place, une « MAC nom » par ligne": "list the exceptions in place, one « MAC name » per line",
"dire la version, puis sortir": "print the version, then exit",
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched",
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched",
"lire des lignes « MÉTHODE URL » sur l'entrée standard et EFFACER du magasin ce que chacune désigne : le corps ET le statut seul. Dit, par ligne et séparé par des tabulations, « verdict octets classe méthode url », verdict oublié, absent, non-cachable ou refus. Le seul geste qui retire UNE entrée : --purge efface tout, et --purge-older-than n'atteint jamais un objet qui sert, chaque service remettant sa date à maintenant. Écrit : demande le droit du service. Pas d'essai à blanc — --detient EST l'aperçu : il répond aux mêmes lignes, par les mêmes clés, sans rien modifier": "read « METHOD URL » lines on standard input and ERASE from the store what each one designates: the body AND the status-only entry. Says, per line and tab-separated, « verdict bytes class method url », verdict oublié, absent, non-cachable or refus. The only gesture that removes ONE entry: --purge erases everything, and --purge-older-than never reaches an object that serves, every service resetting its date to now. Writes: needs the service's rights. No dry run — --detient IS the preview: it answers the same lines, through the same keys, without modifying anything",
"entrée illisible : %v\n": "unreadable input: %v\n",
"exceptions illisibles : %v\n": "unreadable exceptions: %v\n",
"exception refusée : %v\n": "exception refused: %v\n",

View file

@ -95,6 +95,17 @@ func main() {
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
" --cache-dir suffit, sans privilège, et l'âge des objets"+
" n'est pas touché"))
oublie = flag.Bool("oublie", false,
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+
" EFFACER du magasin ce que chacune désigne : le corps ET le"+
" statut seul. Dit, par ligne et séparé par des tabulations,"+
" « verdict octets classe méthode url », verdict oublié,"+
" absent, non-cachable ou refus. Le seul geste qui retire UNE"+
" entrée : --purge efface tout, et --purge-older-than"+
" n'atteint jamais un objet qui sert, chaque service remettant"+
" sa date à maintenant. Écrit : demande le droit du service."+
" Pas d'essai à blanc — --detient EST l'aperçu : il répond aux"+
" mêmes lignes, par les mêmes clés, sans rien modifier"))
)
flag.Parse()
// Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi
@ -117,6 +128,15 @@ func main() {
}
return
}
// Au même endroit et pour la même raison : la question ne porte que sur
// le magasin. Écrire plutôt que lire ne change pas de quoi elle dépend.
if *oublie {
store := &Store{Dir: *cacheDir}
if err := EcrireOublis(store, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err)
}
return
}
bypass := BypassFile{Path: *bypassFile}
// Les exceptions entrent dans les règles dès leur RENDU : le service les

167
script/qemu_cache/oublie.go Normal file
View file

@ -0,0 +1,167 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"strings"
)
// « --oublie » efface du magasin ce qu'une méthode et une URL désignent.
//
// LE MANQUE QU'IL COMBLE. Rien n'invalide une entrée quand la somme d'un
// fichier servi ne correspond pas : le magasin continue de le rendre, et
// retélécharger ne change rien puisque c'est lui qui répond. Les deux purges
// existantes ne l'atteignent pas — « --purge » efface TOUT, et
// « --purge-older-than » saute ce dont la date est récente, or Store.Get remet
// cette date à maintenant à CHAQUE service. Un objet empoisonné qui continue
// d'être servi rajeunit donc à chaque VM et ne vieillit jamais : aucun délai
// ne le rattrape.
//
// SYMÉTRIQUE DE « --detient », et c'est ce qui le rend sûr : mêmes lignes en
// entrée, mêmes fonctions de classement et de clé, mêmes verdicts de refus.
// Ce que l'un dit « garde », l'autre l'efface ; ce que l'un dit
// « non-cachable », l'autre n'a rien à effacer. Deux calculs de clé finiraient
// par diverger, et l'on croirait avoir effacé ce que le service sert encore.
//
// ÉCRITURE : le service écrit ses objets en root, et les effacer demande le
// même droit. Un refus est DIT plutôt que compté comme un succès — croire un
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
// Verdicts de « --oublie ».
const (
// OubliEfface : le magasin tenait quelque chose, il ne l'a plus.
OubliEfface = "oublié"
// OubliAbsent : la requête est cachable, le magasin ne tenait rien.
OubliAbsent = "absent"
// OubliNonCachable : le magasin ne tiendra jamais cette requête, il n'y
// a donc rien à y effacer.
OubliNonCachable = "non-cachable"
// OubliRefus : un objet était là et n'a pas pu être retiré — droits,
// système de fichiers en lecture seule. Surtout pas « oublié ».
OubliRefus = "refus"
)
// Oubli dit ce que le magasin a cessé de tenir pour une méthode et une URL.
type Oubli struct {
Verdict string
// Octets rendus au disque. Nul quand rien n'a été effacé.
Octets int64
Classe string
Methode string
URL string
}
// Ligne rend l'oubli en une ligne séparée par des tabulations : verdict,
// octets, classe, méthode, URL. Un champ sans valeur vaut « - », pour que
// chaque ligne ait cinq champs.
func (o Oubli) Ligne() string {
octets, classe := "-", "-"
if o.Octets != 0 {
octets = fmt.Sprintf("%d", o.Octets)
}
if o.Classe != "" {
classe = o.Classe
}
return strings.Join(
[]string{o.Verdict, octets, classe, o.Methode, o.URL}, "\t")
}
// Oublier efface du magasin ce que la requête désigne, et dit ce qu'il en est.
//
// LES DEUX CLÉS, toujours : le corps (CleDe) et le statut seul (CleStatut)
// vivent dans des espaces séparés, et n'en effacer qu'une laisserait le rejeu
// resservir une redirection vers l'octet qu'on vient de retirer.
//
// Le méta part AVEC le corps, comme dans Purger : un méta orphelin ferait
// croire à une copie présente, et la lecture échouerait au moment de servir.
func (s *Store) Oublier(methode, brut string) Oubli {
methode = strings.ToUpper(methode)
o := Oubli{Verdict: OubliNonCachable, Methode: methode, URL: brut}
u, err := url.Parse(brut)
if err != nil || !u.IsAbs() || u.Host == "" {
return o
}
class := Classify(u)
o.Classe = class.String()
if !CacheableMethod(methode) || class == ClassNoStore {
return o
}
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
return o
}
o.Verdict = OubliAbsent
for _, key := range []string{
CleDe(methode, u), CleStatut(methode, u),
} {
octets, etat := s.effacer(key)
o.Octets += octets
switch etat {
case OubliRefus:
o.Verdict = OubliRefus
case OubliEfface:
if o.Verdict != OubliRefus {
o.Verdict = OubliEfface
}
}
}
return o
}
// effacer retire le corps et le méta d'une clé. Rend (octets rendus, verdict
// de cette clé seule).
//
// Un fichier absent n'est pas une erreur : une clé sans objet est le cas
// ordinaire, et deux clés sont essayées pour chaque requête. Un fichier
// PRÉSENT qui résiste en est une, et elle remonte.
func (s *Store) effacer(key string) (int64, string) {
metaPath, bodyPath := s.paths(key)
octets := int64(0)
etat := OubliAbsent
if fi, err := os.Stat(bodyPath); err == nil {
if e := os.Remove(bodyPath); e != nil {
return 0, OubliRefus
}
octets = fi.Size()
etat = OubliEfface
}
if _, err := os.Stat(metaPath); err == nil {
if e := os.Remove(metaPath); e != nil {
return octets, OubliRefus
}
etat = OubliEfface
}
return octets, etat
}
// EcrireOublis lit des lignes « MÉTHODE URL » et écrit une ligne d'oubli pour
// chacune, dans l'ordre. Une ligne vide ou commençant par « # » est sautée ;
// une ligne d'un seul champ vaut « GET <champ> ». Chaque réponse part aussitôt
// écrite, comme pour « --detient ».
func EcrireOublis(s *Store, entree io.Reader, sortie io.Writer) error {
sc := bufio.NewScanner(entree)
// Une URL signée dépasse volontiers les 64 Kio de la ligne par défaut.
sc.Buffer(make([]byte, 64*1024), 1<<20)
w := bufio.NewWriter(sortie)
for sc.Scan() {
ligne := strings.TrimSpace(sc.Text())
if ligne == "" || strings.HasPrefix(ligne, "#") {
continue
}
champs := strings.Fields(ligne)
methode, brut := "GET", champs[0]
if len(champs) >= 2 {
methode, brut = champs[0], champs[1]
}
fmt.Fprintln(w, s.Oublier(methode, brut).Ligne())
if err := w.Flush(); err != nil {
return err
}
}
return sc.Err()
}

View file

@ -0,0 +1,188 @@
// © 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()) })
}

View file

@ -403,6 +403,27 @@ class TestLesCommandes(unittest.TestCase):
self.assertIn("if [ -s", cmd)
self.assertIn("wget", cmd)
def test_a_substituted_image_names_what_to_do_first(self):
"""Cet hôte peut être une VM derrière le cache de téléchargement :
effacer l'image la fera resservir à l'identique depuis le magasin.
L'entrée s'en retire d'abord, et l'échec le dit — c'est là que
l'opérateur se tient quand la somme lâche."""
cmd = pve.image_fetch_cmd(
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
)
self.assertIn("--oublie", cmd)
self.assertIn("rm -f", cmd)
# Vers l'erreur standard : la sortie normale est lue par la machine.
self.assertIn(">&2", cmd)
def test_the_failure_is_still_a_failure(self):
"""Un message n'absout pas : « false » garde le code de retour, sans
quoi le déploiement continuerait sur une image substituée."""
cmd = pve.image_fetch_cmd(
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
)
self.assertIn("false;", cmd)
def test_the_internal_bridge_never_touches_a_physical_nic(self):
"""Le point le plus important de ce module : ajouter l'interface au
pont déplace l'adresse de l'hôte et coupe la session SSH — à distance,

View file

@ -126,6 +126,28 @@ class LImage(unittest.TestCase):
"""Leur somme, quand elle existe, vient de leur propre SHA256SUMS."""
DQ.verify_pinned_sha256("ubuntu", Path("/inexistant"), False)
def _refus(self, urls=()):
with mock.patch.object(DQ, "open", mock.mock_open(read_data=b"x")):
with self.assertRaises(SystemExit) as pris:
DQ.verify_pinned_sha256("nixos", Path("/x"), False, urls)
return str(pris.exception)
def test_the_cache_entry_is_named_as_what_must_go_first(self):
"""Le remède ordinaire — effacer le fichier — ne suffit PAS derrière
le cache : c'est lui qui resservira les mêmes octets, indéfiniment.
Ni « --purge », qui efface tout, ni « --purge-older-than », qu'un
objet servi rajeunit à chaque fois, ne l'atteignent."""
msg = self._refus(("https://miroir.example/nixos.qcow2",))
self.assertIn("--oublie", msg)
self.assertIn("GET https://miroir.example/nixos.qcow2", msg)
def test_without_a_url_the_message_stays_short(self):
"""Une commande qu'on ne peut pas recopier telle quelle vaut moins
que pas de commande du tout."""
msg = self._refus()
self.assertNotIn("--oublie", msg)
self.assertIn("attendu", msg)
class CeQuiSeDitALEcran(unittest.TestCase):
def test_the_third_party_origin_is_named(self):