`vigia-parches.py --all` sobre las 99 aplicaciones de parche del catálogo: **0 FALLA**, y 14 líneas
FUZZ (8 pares parche/fuente distintos). FUZZ significa que `patch` metió el cambio ADIVINANDO dónde
porque el contexto no casaba, y si cayó en el sitio correcto sólo lo dice el diff. Nadie los había
mirado. Los miré todos:
· **libxml2 / CVE-2026-6732** — el que más importaba, un parche de seguridad con fuzz 2 en sus dos
hunks. Cayó bien: los dos dentro de `xmlParseReference()`, las 4 llamadas pasan `ctxt->userData`
y no sobrevive ningún `sax->characters(ctxt,` en el fichero. El fuzz era por un offset de 226
líneas, no por el sitio.
· **doas / rowhammer** — el otro sensible, toca la decisión de privilegio. Cae dentro de
`checkconfig()` y queda `rv=permit(...); if(rv==0)→permit`, coherente con el `if(rv!=0)→EPERM`
de `main()`. Y el fuzz lo causa un parche ANTERIOR de la propia cadena (el `#ifdef DOAS_CONFDIR`
que inserta `configuration-directory.patch`), no un cambio de upstream — que es una causa que no
se me habría ocurrido sin abrirlo.
· **wayland**, **mesa** (×3), **cairo** (×2), **firefox** (time64 y fix-rust-target): todos en su
sitio, cada uno comprobado contra lo que el propio parche declara querer.
· **gnupg / 0001-include-unistd** — hallazgo: el parche está OBSOLETO. Añade `#include <unistd.h>`
y upstream YA lo trae dos líneas más abajo, así que sólo lo duplica. Inocuo (el header tiene
guardas) y por eso el fuzz 2: cambió el contexto porque upstream lo incorporó. Quitarlo re-hashea
gnupg, así que se paga cuando se re-selle por otro motivo.
Y para que esto no se repregunte en cada corrida —lo que vuelve ruido al vigía, y así es como se
pierde el FALLA del día que aparezca— los veredictos van a `docs/state/fuzz-verificado.tsv` con un
cuarto estado, FUZZ✓.
La clave del libro NO es (receta, parche) sino (receta, parche, HUELLA), donde la huella resume el
texto del parche MÁS el pin de la fuente. Tocá el parche o subí la versión y la huella cambia, la
entrada deja de casar y el vigía vuelve a preguntar. Es lo contrario de una lista de excepciones: no
hay forma de silenciar algo y que siga silenciado cuando cambió. Y el vigía imprime la línea lista
para pegar debajo de cada FUZZ sin verificar, porque calcular la huella a mano es justo la fricción
que hace que nadie lo anote.
Los dos FUZZ de waterfox quedan FUERA del libro a propósito: no los verifiqué en esta ronda y
waterfox está fuera de alcance. Van a seguir saliendo como FUZZ, que es lo honesto — el libro dice
lo que se miró, no lo que se supone.
301 lines
15 KiB
Python
Executable File
301 lines
15 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# vigia-parches.py — ¿los parches de la receta AGARRAN todavía en la fuente que la receta pinea?
|
|
#
|
|
# ══ EL PUNTO CIEGO QUE ESTE VIGÍA CUBRE ════════════════════════════════════════════════════════
|
|
# `hammer build` aplica los parches DESPUÉS de materializar las dependencias. En una receta hoja de
|
|
# la plataforma Gecko eso significa que el `patch` que no agarra se descubre detrás de horas de
|
|
# compilar OTRA COSA: waterfox estrena su primer build reconstruyendo nodejs (4414 objetivos de V8,
|
|
# a `-j2` porque el propio recipe capa por RAM). El parche que falla es el paso 3 de un camino cuyo
|
|
# paso 1 cuesta media noche, y el fallo no dice nada que no se supiera antes de empezar.
|
|
#
|
|
# La receta de waterfox además hace una APUESTA EXPLÍCITA y escrita: la base es Gecko 153.1.0 y los
|
|
# once parches de musl son los de Firefox 154, una release por encima. Su propio comentario dice
|
|
# «si `patch` falla, falla TEMPRANO, antes de compilar nada». Con el orden real de `hammer build`
|
|
# eso no es cierto: falla tarde. Este vigía es lo que lo vuelve cierto.
|
|
#
|
|
# ══ Y EL SEGUNDO PUNTO CIEGO, QUE ES EL CARO ═══════════════════════════════════════════════════
|
|
# `patch` no responde sí/no: responde sí, sí-con-desplazamiento y **sí-con-fuzz**. El fuzz es que
|
|
# las líneas de CONTEXTO no coinciden y `patch` decidió igual dónde meter el cambio, adivinando. Con
|
|
# `--silent` —que es como lo llama `fetch.rs`— eso sale por exit 0 y NADIE SE ENTERA. Un parche que
|
|
# entra con fuzz puede haber editado el sitio correcto (y en waterfox los dos que lo hacen editan el
|
|
# sitio correcto: el contexto que no casa son comillas simples vs dobles de un `black`, y un brazo
|
|
# `cfg` de más) o puede haber editado otro. La diferencia no la ve el exit code: hay que MIRARLA.
|
|
#
|
|
# Por eso esto no reporta «✓/✗» sino tres estados, y el del medio es el que existe para no perderse:
|
|
#
|
|
# ok entra limpio, sin fuzz. Con desplazamiento está bien: el desplazamiento sólo dice
|
|
# que el fichero creció por arriba, no que el contexto haya cambiado.
|
|
# FUZZ entra, pero adivinando. NO es un fallo y no rompe el build — es la señal de que hay
|
|
# que abrir el diff y confirmar a mano que el cambio cayó donde debía.
|
|
# FUZZ✓ entra adivinando, PERO alguien ya abrió el diff y anotó dónde cayó, en
|
|
# `docs/state/fuzz-verificado.tsv`. La entrada está indexada por una huella del parche
|
|
# MÁS el pin de la fuente: si cualquiera de los dos cambia, deja de casar y vuelve a
|
|
# salir como FUZZ. Un fuzz mirado no debería preguntarse en cada corrida —eso vuelve
|
|
# ruido al vigía y así es como se pierde el FALLA del día que aparezca— pero tampoco
|
|
# debe poder silenciarse para siempre.
|
|
# FALLA no entra. Esto sí es el muro, y es el que queremos ver en dos minutos y no en cuatro
|
|
# horas. El arreglo es rebasar el parche que se queje, no bajar el listón del vigía.
|
|
#
|
|
# ══ POR QUÉ ES BARATO, QUE ES LO QUE LO HACE USABLE ════════════════════════════════════════════
|
|
# No baja el árbol. Saca de los propios parches los ficheros que tocan —21 en el caso de waterfox—
|
|
# y para una fuente git hace `--filter=blob:none` + sparse-checkout de ESOS. Un árbol Gecko entero
|
|
# se vuelve dos docenas de blobs. Para un tarball no hay atajo equivalente, así que se baja una vez
|
|
# y se extrae sólo lo que hace falta; sigue siendo órdenes de magnitud menos que construir.
|
|
#
|
|
# ══ SE APLICAN ACUMULATIVOS, COMO EN EL BUILD DE VERDAD ════════════════════════════════════════
|
|
# `fetch.rs::apply_patches` recorre `source.patches` EN ORDEN sobre el mismo árbol, con
|
|
# `patch -p1 --batch --silent`. Probar cada parche por separado contra el árbol virgen da el mismo
|
|
# resultado sólo mientras no haya dos que toquen el mismo fichero — y eso es una propiedad de hoy,
|
|
# no una garantía. Así que acá se aplican de verdad, uno tras otro, en el mismo árbol y en el mismo
|
|
# orden, y se conserva `-p1 --batch`. Lo único que se agrega es `--verbose`, para poder LEER lo que
|
|
# `--silent` calla.
|
|
#
|
|
# Uso:
|
|
# scripts/vigia-parches.py recipes/waterfox.toml
|
|
# scripts/vigia-parches.py --all # las ~45 recetas del árbol que declaran parches
|
|
# scripts/vigia-parches.py --all --git-only # sólo fuentes git (baratas); saltea los tarballs
|
|
# scripts/vigia-parches.py recipes/x.toml --keep # deja el árbol para inspeccionar el diff
|
|
#
|
|
# Exit: 0 si todos los parches entran. 1 si alguno FALLA. Con `--strict`, también 1 si alguno
|
|
# entra con FUZZ — que es lo que se quiere en CI, y no lo que se quiere mirando a mano.
|
|
|
|
import argparse
|
|
import os
|
|
import hashlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import tomllib
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
# `patch` reporta el desplazamiento y el fuzz por hunk, en stdout, sólo con --verbose.
|
|
RE_HUNK = re.compile(r"^Hunk #(\d+) (succeeded|FAILED) at (\d+)(.*)$", re.M)
|
|
RE_FUZZ = re.compile(r"with fuzz (\d+)")
|
|
|
|
# ── EL LIBRO DE FUZZ YA MIRADOS ────────────────────────────────────────────────────────────────
|
|
# Un FUZZ no se puede resolver automáticamente: hay que abrir el diff y ver si el cambio cayó donde
|
|
# debía. Pero una vez mirado, el veredicto NO caduca solo — depende de dos cosas y las dos están
|
|
# pineadas: el TEXTO del parche y la FUENTE. Repreguntarlo en cada corrida convierte al vigía en
|
|
# ruido y se acaba ignorando (que es como se pierde el FALLA del día que aparezca).
|
|
#
|
|
# Por eso el libro se indexa por una HUELLA de las dos cosas. Si alguien toca el parche, o sube la
|
|
# versión de la receta, la huella cambia, la entrada deja de casar y el vigía vuelve a preguntar.
|
|
# Es lo contrario de una lista de excepciones: no hay forma de silenciar algo y que se quede
|
|
# silenciado cuando cambió.
|
|
LIBRO = Path(__file__).resolve().parent.parent / "docs/state/fuzz-verificado.tsv"
|
|
|
|
|
|
def huella(texto_parche: str, src: dict) -> str:
|
|
"""Identidad de un (parche, fuente) pineados. Cambiá cualquiera de los dos y cambia."""
|
|
pin = src.get("sha256") or src.get("commit") or ""
|
|
h = hashlib.sha256()
|
|
h.update(texto_parche.encode())
|
|
h.update(b"\n")
|
|
h.update(pin.encode())
|
|
return h.hexdigest()[:16]
|
|
|
|
|
|
def cargar_libro() -> dict:
|
|
if not LIBRO.exists():
|
|
return {}
|
|
out = {}
|
|
for linea in LIBRO.read_text(encoding="utf8").splitlines():
|
|
if linea.startswith("#") or not linea.strip():
|
|
continue
|
|
campos = linea.split("\t")
|
|
if len(campos) >= 4:
|
|
out[(campos[0], campos[1], campos[2])] = campos[3]
|
|
return out
|
|
RE_OFFSET = re.compile(r"offset (-?\d+) lines?")
|
|
|
|
|
|
def ficheros_del_parche(texto: str) -> list[str]:
|
|
"""Los caminos que el parche toca, tal como los verá `patch -p1`.
|
|
|
|
Se leen del CUERPO (`---`/`+++`), nunca del diffstat de la cabecera: un parche recortado a mano
|
|
conserva el diffstat del commit original y entonces la cabecera MIENTE. Pasa hoy mismo —
|
|
`firefox-patches/time64.patch` anuncia tres ficheros y entrega dos (le falta el hunk de
|
|
`wgpu-hal`), y como firefox 154 sella igual, nadie lo había notado.
|
|
|
|
Se descarta SIEMPRE el primer componente, se llame como se llame, porque eso es exactamente lo
|
|
que significa el `-p1` con el que `fetch.rs` los aplica. Suponer el prefijo `a/`+`b/` de git es
|
|
un error: `echild-strerror.patch` (gawk) viene de un `diff -upr` a secas y sus caminos empiezan
|
|
en `gawk-5.1.0.orig/` y `gawk-5.1.0/`. Con la suposición de git ese parche daba «can't find file
|
|
to patch» sobre una receta que sella perfecto — o sea, el vigía mintiendo en la dirección cara.
|
|
"""
|
|
out = []
|
|
for m in re.finditer(r"^(?:---|\+\+\+) ([^\s\t]+)", texto, re.M):
|
|
p = m.group(1)
|
|
if p == "/dev/null":
|
|
continue
|
|
if "/" in p:
|
|
out.append(p.split("/", 1)[1])
|
|
return sorted(set(out))
|
|
|
|
|
|
def traer_git(repo: str, commit: str, ficheros: list[str], dest: Path) -> None:
|
|
"""Sparse-checkout de sólo `ficheros` en `commit`. No trae historia ni blobs de más."""
|
|
run = lambda *a: subprocess.run(a, cwd=dest, check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
run("git", "init", "-q", ".")
|
|
run("git", "remote", "add", "origin", repo)
|
|
run("git", "config", "core.sparseCheckout", "true")
|
|
subprocess.run(["git", "fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit],
|
|
cwd=dest, check=True)
|
|
(dest / ".git" / "info" / "sparse-checkout").write_text(
|
|
"".join(f"/{f}\n" for f in ficheros), encoding="utf8")
|
|
run("git", "checkout", "-q", "FETCH_HEAD")
|
|
|
|
|
|
def traer_tarball(url: str, ficheros: list[str], dest: Path) -> None:
|
|
"""Sin atajo posible: se baja el tarball y se extraen sólo los miembros que hacen falta.
|
|
|
|
El primer componente del camino se descarta (equivale al `-p1` con el que se aplican después),
|
|
igual que hace hammer al extraer.
|
|
"""
|
|
quiero = set(ficheros)
|
|
with tempfile.NamedTemporaryFile(suffix=".tar", delete=True) as tmp:
|
|
with urllib.request.urlopen(url) as r:
|
|
shutil.copyfileobj(r, tmp)
|
|
tmp.flush()
|
|
with tarfile.open(tmp.name, "r:*") as tf:
|
|
for m in tf:
|
|
if not m.isfile():
|
|
continue
|
|
rel = m.name.split("/", 1)[1] if "/" in m.name else m.name
|
|
if rel not in quiero:
|
|
continue
|
|
destino = dest / rel
|
|
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
src = tf.extractfile(m)
|
|
if src:
|
|
destino.write_bytes(src.read())
|
|
|
|
|
|
def probar(receta: Path, keep: bool, git_only: bool, libro: dict) -> tuple[str, list[str]]:
|
|
"""Devuelve (estado, líneas del informe) para una receta. Estado ∈ ok|fuzz|falla|saltada."""
|
|
datos = tomllib.loads(receta.read_text(encoding="utf8"))
|
|
src = datos.get("source", {})
|
|
parches = src.get("patches") or []
|
|
nombre = datos.get("name", receta.stem)
|
|
if not parches:
|
|
return "saltada", [f"{nombre}: sin parches"]
|
|
|
|
textos = {}
|
|
ficheros: set[str] = set()
|
|
for p in parches:
|
|
ruta = receta.parent / p
|
|
if not ruta.exists():
|
|
return "falla", [f"{nombre}: ✗ el parche declarado no existe en el disco: {p}"]
|
|
t = ruta.read_text(encoding="utf8", errors="replace")
|
|
textos[p] = t
|
|
ficheros.update(ficheros_del_parche(t))
|
|
|
|
es_git = "repo" in src
|
|
if not es_git and git_only:
|
|
return "saltada", [f"{nombre}: tarball, salteada por --git-only"]
|
|
|
|
tmp = Path(tempfile.mkdtemp(prefix=f"vigia-parches-{nombre}-"))
|
|
try:
|
|
if es_git:
|
|
commit = src.get("commit")
|
|
if not commit or len(commit) < 40:
|
|
# Un tag flotante no es un pin (ADR 0006); tampoco se puede verificar contra él.
|
|
return "falla", [f"{nombre}: ✗ `commit` no es un sha completo: {commit!r}"]
|
|
traer_git(src["repo"], commit, sorted(ficheros), tmp)
|
|
else:
|
|
url = src.get("tarball") or src.get("url")
|
|
if not url:
|
|
return "saltada", [f"{nombre}: [source] sin `repo` ni `tarball`"]
|
|
traer_tarball(url, sorted(ficheros), tmp)
|
|
|
|
lineas = [f"{nombre} ({datos.get('version','?')}) — {len(parches)} parches, "
|
|
f"{len(ficheros)} ficheros tocados"]
|
|
peor = "ok"
|
|
# Acumulativos y en orden, igual que fetch.rs::apply_patches.
|
|
for p in parches:
|
|
r = subprocess.run(["patch", "-p1", "--batch", "--verbose"],
|
|
cwd=tmp, input=textos[p], text=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
hunks = RE_HUNK.findall(r.stdout)
|
|
fuzz = [(h[0], RE_FUZZ.search(h[3]).group(1)) for h in hunks if RE_FUZZ.search(h[3])]
|
|
offs = [h[0] for h in hunks if RE_OFFSET.search(h[3])]
|
|
base = os.path.basename(p)
|
|
if r.returncode != 0:
|
|
peor = "falla"
|
|
lineas.append(f" FALLA {base}")
|
|
for l in r.stdout.splitlines():
|
|
if "FAILED" in l or "can't find file" in l or "Reversed" in l:
|
|
lineas.append(f" {l.strip()}")
|
|
elif fuzz:
|
|
det = ", ".join(f"hunk #{h} fuzz {f}" for h, f in fuzz)
|
|
hu = huella(textos[p], src)
|
|
visto = libro.get((nombre, base, hu))
|
|
if visto:
|
|
lineas.append(f" FUZZ✓ {base} ({det}) — ya mirado: {visto}")
|
|
else:
|
|
if peor != "falla":
|
|
peor = "fuzz"
|
|
lineas.append(f" FUZZ {base} ({det}) — entra ADIVINANDO, mirá el diff")
|
|
# La línea lista para pegar en el libro DESPUÉS de mirarlo. Se da acá porque
|
|
# calcular la huella a mano es justo la fricción que hace que nadie lo anote.
|
|
lineas.append(f" libro: {nombre}\t{base}\t{hu}\t<tu veredicto>")
|
|
else:
|
|
extra = f" ({len(offs)} hunks desplazados)" if offs else ""
|
|
lineas.append(f" ok {base}{extra}")
|
|
if keep:
|
|
lineas.append(f" árbol parcheado en {tmp}")
|
|
return peor, lineas
|
|
finally:
|
|
if not keep:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="¿los parches de una receta siguen agarrando?")
|
|
ap.add_argument("recipes", nargs="*", type=Path)
|
|
ap.add_argument("--all", action="store_true", help="todas las recetas que declaren parches")
|
|
ap.add_argument("--git-only", action="store_true", help="saltear fuentes tarball (más caras)")
|
|
ap.add_argument("--keep", action="store_true", help="no borrar el árbol parcheado")
|
|
ap.add_argument("--strict", action="store_true", help="salir 1 también si algo entra con fuzz")
|
|
a = ap.parse_args()
|
|
|
|
objetivos = list(a.recipes)
|
|
if a.all:
|
|
for f in sorted((ROOT / "recipes").glob("*.toml")):
|
|
if re.search(r"^\s*patches\s*=", f.read_text(encoding="utf8", errors="replace"), re.M):
|
|
objetivos.append(f)
|
|
if not objetivos:
|
|
ap.error("dame una receta o --all")
|
|
|
|
peor = "ok"
|
|
libro = cargar_libro()
|
|
orden = {"saltada": 0, "ok": 1, "fuzz": 2, "falla": 3}
|
|
for r in objetivos:
|
|
try:
|
|
estado, lineas = probar(r, a.keep, a.git_only, libro)
|
|
except subprocess.CalledProcessError as e:
|
|
estado, lineas = "falla", [f"{r}: ✗ no pude traer la fuente ({e})"]
|
|
except Exception as e: # una receta rota no debe tumbar el barrido entero
|
|
estado, lineas = "falla", [f"{r}: ✗ {type(e).__name__}: {e}"]
|
|
print("\n".join(lineas))
|
|
print()
|
|
if orden[estado] > orden[peor]:
|
|
peor = estado
|
|
|
|
if peor == "falla":
|
|
return 1
|
|
if peor == "fuzz" and a.strict:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|