234 lines
8.2 KiB
Go
234 lines
8.2 KiB
Go
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/url"
|
||
|
|
"path"
|
||
|
|
"regexp"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Class dit ce que le cache a le droit de faire d'une réponse.
|
||
|
|
//
|
||
|
|
// La distinction porte tout l'outil. Un fichier de paquet est IMMUABLE :
|
||
|
|
// son nom porte sa version, son contenu ne change jamais, et le servir du
|
||
|
|
// disque est exactement le gain cherché. Un index de dépôt est VOLATILE :
|
||
|
|
// il nomme les versions qui existent en ce moment, et servir un index périmé
|
||
|
|
// fait échouer l'installation sur un fichier retiré des miroirs — « failed
|
||
|
|
// retrieving file … 404 », le mode de défaillance que la préparation d'un
|
||
|
|
// invité Arch documente déjà.
|
||
|
|
//
|
||
|
|
// D'où la règle : seul l'immuable est servi du cache tant que l'amont répond.
|
||
|
|
// Le volatile est STOCKÉ quand même, et ne ressort que si l'amont est
|
||
|
|
// injoignable, ce qui rend le déploiement hors ligne possible sans jamais
|
||
|
|
// ouvrir de fenêtre de péremption quand le réseau est là.
|
||
|
|
type Class int
|
||
|
|
|
||
|
|
const (
|
||
|
|
// ClassImmutable : servi du disque dès qu'il y est.
|
||
|
|
ClassImmutable Class = iota
|
||
|
|
// ClassVolatile : toujours pris à l'amont, stocké, servi hors ligne seul.
|
||
|
|
ClassVolatile
|
||
|
|
// ClassNoStore : ni servi ni stocké.
|
||
|
|
ClassNoStore
|
||
|
|
)
|
||
|
|
|
||
|
|
func (c Class) String() string {
|
||
|
|
switch c {
|
||
|
|
case ClassImmutable:
|
||
|
|
return "immutable"
|
||
|
|
case ClassVolatile:
|
||
|
|
return "volatile"
|
||
|
|
default:
|
||
|
|
return "no-store"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Suffixes d'un fichier dont le nom porte sa version. L'ordre n'importe pas ;
|
||
|
|
// le premier qui correspond gagne.
|
||
|
|
var immutableSuffixes = []string{
|
||
|
|
// paquets de distribution
|
||
|
|
".pkg.tar.zst", ".pkg.tar.xz", ".deb", ".rpm", ".apk",
|
||
|
|
// signatures détachées, aussi figées que ce qu'elles signent
|
||
|
|
".pkg.tar.zst.sig", ".pkg.tar.xz.sig",
|
||
|
|
// écosystèmes Python et Node
|
||
|
|
".whl", ".tgz",
|
||
|
|
// images et supports d'installation
|
||
|
|
".qcow2", ".iso", ".img", ".raw", ".vmdk",
|
||
|
|
// archives amont
|
||
|
|
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".zip",
|
||
|
|
}
|
||
|
|
|
||
|
|
// Noms et suffixes d'un index de dépôt. Un « .db » d'Arch, un « InRelease »
|
||
|
|
// de Debian et un « repomd.xml » de Fedora décrivent tous l'état COURANT du
|
||
|
|
// miroir.
|
||
|
|
var volatileNames = []string{
|
||
|
|
"inrelease", "release", "release.gpg",
|
||
|
|
"packages", "packages.gz", "packages.xz", "packages.bz2",
|
||
|
|
"sources", "sources.gz", "sources.xz",
|
||
|
|
"repomd.xml", "repomd.xml.asc", "repomd.xml.key",
|
||
|
|
"index.json", "index.html",
|
||
|
|
}
|
||
|
|
|
||
|
|
var volatileSuffixes = []string{
|
||
|
|
".db", ".db.sig", ".db.tar.gz", ".files", ".files.tar.gz",
|
||
|
|
".xml.gz", ".xml.zck", ".sqlite.bz2", ".sqlite.gz",
|
||
|
|
}
|
||
|
|
|
||
|
|
// parEmpreinte reconnaît un index Debian publié sous l'empreinte de son
|
||
|
|
// contenu : « …/by-hash/SHA256/<hexadécimal> ». Le nom EST la somme du
|
||
|
|
// contenu, si bien qu'un contenu différent porte un autre nom : le fichier est
|
||
|
|
// aussi figé qu'un paquet, quoiqu'il n'ait aucune extension.
|
||
|
|
//
|
||
|
|
// Il se range SANS son hôte, et c'est ce qui le distingue d'un index nommé
|
||
|
|
// par sa suite : deux miroirs qui servent ce chemin servent le même octet,
|
||
|
|
// ou l'un des deux est corrompu et son client le rejette sur la somme. Le
|
||
|
|
// raisonnement inverse — « le même chemin ne vaut que si le miroir publie la
|
||
|
|
// même suite » — vaut pour « Packages », dont le contenu varie d'un miroir à
|
||
|
|
// l'autre sous un nom identique ; il ne vaut pas pour un nom qui EST la somme
|
||
|
|
// de son contenu, et l'appliquer ici vide le cache de ses index dès qu'une
|
||
|
|
// liste de miroirs tourne.
|
||
|
|
var parEmpreinte = regexp.MustCompile(
|
||
|
|
`/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`)
|
||
|
|
|
||
|
|
// dernierePublication reconnaît « /<propriétaire>/<dépôt>/releases/latest/
|
||
|
|
// download/<fichier> » : un POINTEUR vers la dernière version publiée, dont
|
||
|
|
// la cible change à chaque publication.
|
||
|
|
//
|
||
|
|
// Son suffixe — « .tar.gz », « .zip » — le ferait passer pour figé : servi du
|
||
|
|
// disque sans jamais redemander, il resterait à la première version vue, et
|
||
|
|
// rangé sans son hôte il répondrait pour n'importe quelle forge. Il est donc
|
||
|
|
// volatile et attaché à son hôte, et la règle passe AVANT les suffixes.
|
||
|
|
var dernierePublication = regexp.MustCompile(
|
||
|
|
`^/[^/]+/[^/]+/releases/latest/download/[^/]+$`)
|
||
|
|
|
||
|
|
// Chemins du protocole « smart HTTP » de git. Ce sont des points de
|
||
|
|
// NÉGOCIATION : le serveur calcule sa réponse en fonction de ce que le client
|
||
|
|
// détient déjà. Rien n'y est réutilisable d'une requête à l'autre, et servir
|
||
|
|
// une réponse gardée y ferait croire à des références qui n'existent plus.
|
||
|
|
//
|
||
|
|
// Les fichiers du protocole « dumb », eux, restent cachables : un objet
|
||
|
|
// « .../objects/ab/cdef… » porte son empreinte dans son nom.
|
||
|
|
var gitSmartPaths = []string{
|
||
|
|
"/info/refs", "/git-upload-pack", "/git-receive-pack",
|
||
|
|
}
|
||
|
|
|
||
|
|
// EstGitSmart dit si l'URL vise l'un de ces points de négociation.
|
||
|
|
//
|
||
|
|
// Sert deux fois. Le contenu n'est ni gardé ni servi du cache. Et l'amont y a
|
||
|
|
// droit à BEAUCOUP plus de patience : un serveur git énumère ses références à
|
||
|
|
// la demande, ce qui prend des dizaines de secondes sur un dépôt chargé, là où
|
||
|
|
// un miroir de paquets répond en quelques centaines de millisecondes.
|
||
|
|
func EstGitSmart(u *url.URL) bool {
|
||
|
|
if u == nil {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
for _, s := range gitSmartPaths {
|
||
|
|
if strings.HasSuffix(u.Path, s) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Classify tranche pour une URL, sans regarder la réponse : la décision doit
|
||
|
|
// être prise AVANT d'interroger l'amont, puisqu'elle décide s'il faut
|
||
|
|
// l'interroger.
|
||
|
|
//
|
||
|
|
// Le doute profite au volatile. Une URL inconnue est donc toujours reprise à
|
||
|
|
// l'amont quand il répond, et ne sert de copie que hors ligne : le pire cas
|
||
|
|
// est une requête inutile, jamais une réponse fausse.
|
||
|
|
func Classify(u *url.URL) Class {
|
||
|
|
if u == nil {
|
||
|
|
return ClassNoStore
|
||
|
|
}
|
||
|
|
if EstGitSmart(u) {
|
||
|
|
return ClassNoStore
|
||
|
|
}
|
||
|
|
name := strings.ToLower(path.Base(u.Path))
|
||
|
|
|
||
|
|
// Une requête portant une chaîne de requête décrit un appel d'API et non
|
||
|
|
// un fichier : deux paramètres différents rendent deux réponses, et son
|
||
|
|
// nom de fichier ne dit rien de son contenu.
|
||
|
|
if u.RawQuery != "" {
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
// Les deux règles de CHEMIN l'emportent sur celles du nom : l'une porte un
|
||
|
|
// suffixe figé qui ment, l'autre n'en porte aucun et dit pourtant vrai.
|
||
|
|
if dernierePublication.MatchString(u.Path) {
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
if parEmpreinte.MatchString(u.Path) {
|
||
|
|
return ClassImmutable
|
||
|
|
}
|
||
|
|
for _, n := range volatileNames {
|
||
|
|
if name == n {
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, s := range volatileSuffixes {
|
||
|
|
if strings.HasSuffix(name, s) {
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// L'index « simple » de PyPI est une page sans extension sous /simple/.
|
||
|
|
if strings.Contains(u.Path, "/simple/") {
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
for _, s := range immutableSuffixes {
|
||
|
|
if strings.HasSuffix(name, s) {
|
||
|
|
return ClassImmutable
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ClassVolatile
|
||
|
|
}
|
||
|
|
|
||
|
|
// PortableParChemin dit si le NOM du fichier suffit à l'identifier sur
|
||
|
|
// n'importe quel miroir de la même distribution.
|
||
|
|
//
|
||
|
|
// Vrai pour un paquet — sa version et son architecture sont dans son nom — et
|
||
|
|
// pour un index de dépôt, dont le chemin est le même partout. Faux pour le
|
||
|
|
// reste : « /index.html » n'identifie rien.
|
||
|
|
func PortableParChemin(u *url.URL) bool {
|
||
|
|
if u == nil || u.RawQuery != "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if dernierePublication.MatchString(u.Path) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
// Un objet adressé par son empreinte se range sans son hôte. La règle est
|
||
|
|
// POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une
|
||
|
|
// somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne
|
||
|
|
// suffirait donc pas à le rendre portable.
|
||
|
|
if parEmpreinte.MatchString(u.Path) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
name := strings.ToLower(path.Base(u.Path))
|
||
|
|
for _, s := range immutableSuffixes {
|
||
|
|
if strings.HasSuffix(name, s) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, s := range volatileSuffixes {
|
||
|
|
if strings.HasSuffix(name, s) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, n := range volatileNames {
|
||
|
|
// « index.html » et « index.json » nomment n'importe quoi : ils sont
|
||
|
|
// dans la table des index, mais pas portables pour autant.
|
||
|
|
if name == n && !strings.HasPrefix(n, "index.") {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// CacheableMethod : seules les lectures entrent au cache. Un POST ou un PUT
|
||
|
|
// change un état à l'amont et n'a pas de copie qui vaille.
|
||
|
|
func CacheableMethod(method string) bool {
|
||
|
|
return method == "GET" || method == "HEAD"
|
||
|
|
}
|