licencias 96% (1129/1165): la fuente git también es evidencia, y el script deja de llamarse «tarball»

Faltaban tres recetas cuya fuente es un repo, no un tarball: `fcft`, `foot` y `glab`. El commit
pineado cumple exactamente el mismo papel que el sha256 —es el árbol EXACTO que construimos— así que
la evidencia estaba igual de disponible, sólo que por otra puerta.

`miembros_de_git()` la abre sin clonar el árbol entero: `--depth 1 --filter=blob:limit=64k` sobre el
commit. Un COPYING nunca pasa de 64k y los fuentes donde vive la concesión tampoco; los que sí pasan
—binarios, assets— son justo los que no interesan. Un repo de cientos de megas se resuelve con unos
pocos. Las tres salieron del `license:` que el propio autor escribe en su `meson.build` (fcft, foot)
y del texto del LICENSE (glab).

Los repos por SSH se saltan diciéndolo: este script hace fetch anónimo y no tiene claves. Son
`hammerd` y `portal-probe`, y ya están resueltas por otra vía (su repo es este mismo).

`analizar()` pasa a consumir un iterable de `(ruta, bytes)` en vez de abrir el tar ella misma, así
que las dos fuentes comparten TODA la lógica de veredicto: la jerarquía de evidencia, la regla de
sólo-la-raíz, la unión de identificaciones y la exigencia de que la concesión hable de la misma
versión que el COPYING. Un criterio, dos puertas.

Y el script se renombra `licencias-tarball.py` → `licencias-fuente.py`, porque el nombre viejo pasó a
ser mentira en el momento en que aprendió a leer git. Un nombre que describe la implementación de
ayer manda a la gente a buscar en el sitio equivocado.

Los 3 ArtifactHash, idénticos antes y después.
This commit is contained in:
Sergio
2026-09-05 18:07:17 +00:00
parent 9e6e3d5aa6
commit b87835f719
5 changed files with 117 additions and 46 deletions
+6 -3
View File
@@ -1,4 +1,4 @@
# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el
# licencias-evidencia.tsv — lo generó scripts/licencias-fuente.py desde el
# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo
# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.
# Se ACUMULA: las filas de lo ya sembrado se conservan como rastro de auditoría.
@@ -8,11 +8,15 @@ dwarves GPL-2.0-only COPYING + concesión en btf_encoder.c: …/* SPDX-License-I
elfutils GPL-3.0-or-later COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di…
elfutils-libdw GPL-3.0-or-later COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di…
exiv2 GPL-2.0-or-later COPYING + concesión en README.md: …ation; either version 2 of the License, or (at your option) any later version. Exiv2 program is…
fcft MIT meson.build: license: 'MIT'
file BSD-2-Clause COPYING (texto de BSD-2-Clause)
foot MIT meson.build: license: 'MIT'
giflib MIT COPYING (texto de MIT)
glab MIT LICENSE (texto de MIT)
go BSD-3-Clause LICENSE (texto de BSD-3-Clause)
gtk4-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
hammer-edit LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
hammerd MIT el repo es el propio hammer: LICENSE (MIT License) + Cargo.toml: license = "MIT"
intltool GPL-2.0-or-later COPYING + concesión en intltool-extract.in: …on; either version 2 of the # License, or (at your option) any later version. # # Intltool is d…
json-c MIT COPYING (texto de MIT)
libogg BSD-3-Clause COPYING (texto de BSD-3-Clause)
@@ -28,6 +32,7 @@ mtdev MIT COPYING (texto de MIT)
nano GPL-3.0-or-later COPYING + concesión en NEWS: …Finally, nano is now licensed under the GNU GPL version 3 or later, and its documentat…
nghttp2 MIT COPYING (texto de MIT)
pcre2-shared BSD-3-Clause LICENCE.md (texto de BSD-3-Clause)
portal-probe MIT el repo es el propio hammer: LICENSE (MIT License) + Cargo.toml: license = "MIT"
scdoc MIT COPYING (texto de MIT)
seatd MIT meson.build: license: 'MIT'
socat GPL-2.0-or-later COPYING + concesión en socat_buildscript_for_android.sh: …; either version 2.1 of the License, or (at your option) any later version. The GNU C Libr…
@@ -35,5 +40,3 @@ sourceview-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
speexdsp BSD-3-Clause COPYING (texto de BSD-3-Clause)
tea MIT LICENSE (texto de MIT)
tllist MIT meson.build: license: 'MIT'
hammerd MIT el repo es el propio hammer: LICENSE (MIT License) + Cargo.toml: license = "MIT"
portal-probe MIT el repo es el propio hammer: LICENSE (MIT License) + Cargo.toml: license = "MIT"
Can't render this file because it contains an unexpected character in line 19 and column 88.
+1
View File
@@ -3,6 +3,7 @@
# harfbuzz/pixman (canónicas). Último eslabón antes del terminal foot. meson, lib estática.
name = "fcft"
version = "3.3.3"
license = "MIT"
[source]
# Fuente GIT por commit, no el archive de codeberg (ADR 0006). El `/archive/3.3.3.tar.gz` lo genera
+1
View File
@@ -4,6 +4,7 @@
# pixman/fontconfig/freetype/harfbuzz/libxkbcommon/ncurses (canónicas). Sin PGO (evita cage).
name = "foot"
version = "1.27.0"
license = "MIT"
[source]
# Fuente GIT por commit, no el archive de codeberg (ADR 0006), por lo mismo que fcft: el
+1
View File
@@ -2,6 +2,7 @@
# El repo tiene varios mains (cmd/glab, cmd/gen-docs); fijamos ./cmd/glab explícito.
name = "glab"
version = "1.102.0"
license = "MIT"
[source]
repo = "https://gitlab.com/gitlab-org/cli"
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""licencias-tarball.py — deduce el SPDX de una receta desde SU PROPIO tarball pineado, offline.
"""licencias-fuente.py — deduce el SPDX de una receta desde SU PROPIA fuente pineada.
QUÉ AÑADE SOBRE LOS HERMANOS
`licencias.sh --sembrar` escribe desde una tabla curada a mano, y `licencias-desambiguar.sh`
@@ -8,7 +8,8 @@ fuera lo mismo: lo que no está en la tabla y lo que no vive en GitHub.
Pero `work/tarballs/` guarda cada tarball indexado por su **sha256**, o sea que el árbol EXACTO que
la receta pinea ya está en disco. La evidencia más fuerte posible la declaración del propio autor
en el commit que construimos no necesita red: necesita abrir el tar.
en el commit que construimos no necesita red: necesita abrir el tar. Y para las recetas de fuente
git, el `commit` pineado cumple el mismo papel que el sha256: se lee sin clonar el árbol entero.
LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL
1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato
@@ -32,15 +33,15 @@ veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano
NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`.
EL MODO `--fetch`
Por defecto NO toca la red: mira sólo lo que ya está en `work/tarballs/`. Con `--fetch` baja los
tarballs que faltan a un temporal, los verifica por sha256 el mismo sha que la receta pinea, así
Por defecto NO toca la red: mira sólo lo que ya está en `work/tarballs/`. Con `--fetch` trae a un
temporal lo que falte tarballs y fuentes git, verifica el tarball por sha256 el mismo sha que la receta pinea, así
que o es el árbol exacto o no se mira y los borra al terminar. No escribe en la caché de hammer a
propósito: la caché es suya y un fichero puesto ahí por otro camino es una vía de envenenamiento
que nadie audita.
Uso: scripts/licencias-tarball.py informe + escribe docs/licencias-evidencia.tsv
scripts/licencias-tarball.py --fetch además baja los tarballs que falten (red)
scripts/licencias-tarball.py <receta> una sola, con el detalle a la vista
Uso: scripts/licencias-fuente.py informe + escribe docs/licencias-evidencia.tsv
scripts/licencias-fuente.py --fetch además trae las fuentes que falten (red)
scripts/licencias-fuente.py <receta> una sola, con el detalle a la vista
"""
import os
import re
@@ -226,8 +227,67 @@ def declaracion_del_autor(ruta, texto):
return None, None
def analizar(tar_path):
"""Un solo recorrido del tar. Devuelve (spdx, cita) o (None, pista)."""
def miembros_de_tar(tar_path):
"""(ruta relativa a la raíz, bytes) de cada fichero del tar. Un solo recorrido, en streaming."""
try:
tf = tarfile.open(tar_path, "r:*")
except Exception as e:
raise RuntimeError(f"no se pudo abrir el tar: {e}")
with tf:
for i, m in enumerate(tf):
if i > MAX_MIEMBROS:
break
if not m.isfile() or m.size > MAX_BYTES:
continue
rel = sin_prefijo(m.name)
if not rel:
continue
try:
yield rel, tf.extractfile(m).read()
except Exception:
continue
def miembros_de_git(repo, commit, tmp):
"""Igual, pero desde un repo git, SIN clonar el árbol entero.
`--depth 1 --filter=blob:limit=64k` sobre el commit PINEADO: trae la historia de un solo commit
y sólo los blobs chicos. Un COPYING nunca pasa de 64k, y los fuentes donde vive la concesión
tampoco los que (binarios, assets) son justo los que no interesan. Así una receta cuyo
árbol pesa cientos de megas se resuelve con unos pocos.
"""
ejecutar = lambda *a: subprocess.run(a, cwd=tmp, capture_output=True, text=False)
subprocess.run(["git", "init", "-q", tmp], capture_output=True)
ejecutar("git", "remote", "add", "origin", repo)
r = ejecutar("git", "fetch", "-q", "--depth", "1", "--filter=blob:limit=64k", "origin", commit)
if r.returncode != 0:
raise RuntimeError("git fetch falló: "
+ r.stderr.decode("utf-8", "replace").strip()[:100])
r = ejecutar("git", "ls-tree", "-r", "-z", "--long", "FETCH_HEAD")
if r.returncode != 0:
raise RuntimeError("git ls-tree falló")
n = 0
for reg in r.stdout.decode("utf-8", "replace").split("\0"):
if not reg or n > MAX_MIEMBROS:
break
meta, _, rel = reg.partition("\t")
campos = meta.split()
# <modo> <tipo> <sha> <tamaño>
if len(campos) < 4 or campos[1] != "blob":
continue
try:
if campos[3] != "-" and int(campos[3]) > MAX_BYTES:
continue
except ValueError:
pass
b = ejecutar("git", "cat-file", "blob", campos[2])
if b.returncode == 0:
n += 1
yield rel, b.stdout
def analizar(miembros):
"""Un solo recorrido de la fuente. Devuelve (spdx, cita) o (None, pista)."""
autor = None
reuse = [] # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
candidatos = [] # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
@@ -239,19 +299,7 @@ def analizar(tar_path):
spdx_headers = {}
try:
tf = tarfile.open(tar_path, "r:*")
except Exception as e:
return None, f"no se pudo abrir el tar: {e}"
with tf:
for i, m in enumerate(tf):
if i > MAX_MIEMBROS:
break
if not m.isfile() or m.size > MAX_BYTES:
continue
rel = sin_prefijo(m.name)
if not rel:
continue
for rel, datos in miembros:
base = os.path.basename(rel)
if rel.startswith("LICENSES/"):
@@ -273,11 +321,6 @@ def analizar(tar_path):
if not (interesa_autor or es_licencia or es_fuente):
continue
try:
datos = tf.extractfile(m).read()
except Exception:
continue
if interesa_autor and autor is None:
lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
if lic:
@@ -309,6 +352,9 @@ def analizar(tar_path):
if mm:
cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}"
except RuntimeError as e:
return None, str(e)
if candidatos:
# Se identifica CADA fichero de la raíz y se unen los resultados. Que haya varios no es
# ambigüedad por sí solo: el `COPYING` de pcre2 son dos líneas que apuntan a `LICENCE.md`,
@@ -408,23 +454,42 @@ def main():
nombre = d["name"]
if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
continue
sha = d.get("source", {}).get("sha256")
src = d.get("source", {})
sha = src.get("sha256")
if not sha:
sin_tar.append((nombre, "fuente git/dir: no hay tarball que mirar"))
continue
tar_path = os.path.join(CACHE, sha + ".tar")
if not os.path.exists(tar_path):
url = d.get("source", {}).get("tarball")
if not (con_red and url):
sin_tar.append((nombre, "tarball no cacheado"
+ ("" if con_red else " (probá --fetch)")))
# ── Fuente git: el commit pineado ES la evidencia, igual que un sha256 ──────────────
repo, commit = src.get("repo"), src.get("commit")
if not (repo and commit):
sin_tar.append((nombre, "fuente sin tarball ni repo/commit"))
continue
tar_path = os.path.join(tmp, sha)
fallo = bajar_verificado(url, sha, tar_path)
if fallo:
sin_tar.append((nombre, fallo))
if repo.startswith(("ssh://", "git@")):
# El fetch de este script es anónimo; un repo por SSH necesita claves que no tiene.
sin_tar.append((nombre, "repo por SSH: este script no autentica"))
continue
spdx, cita = analizar(tar_path)
if not con_red:
sin_tar.append((nombre, "fuente git (probá --fetch)"))
continue
sub = os.path.join(tmp, nombre + "-git")
os.makedirs(sub, exist_ok=True)
try:
spdx, cita = analizar(miembros_de_git(repo, commit, sub))
except RuntimeError as e:
sin_tar.append((nombre, str(e)))
continue
else:
tar_path = os.path.join(CACHE, sha + ".tar")
if not os.path.exists(tar_path):
url = src.get("tarball")
if not (con_red and url):
sin_tar.append((nombre, "tarball no cacheado"
+ ("" if con_red else " (probá --fetch)")))
continue
tar_path = os.path.join(tmp, sha)
fallo = bajar_verificado(url, sha, tar_path)
if fallo:
sin_tar.append((nombre, fallo))
continue
spdx, cita = analizar(miembros_de_tar(tar_path))
if spdx:
filas.append((nombre, spdx, una_linea(cita)))
else:
@@ -456,14 +521,14 @@ def main():
previas[n] = (n, sp, c)
with open(SALIDA, "w") as fh:
fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el\n")
fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-fuente.py desde el\n")
fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
fh.write("# Se ACUMULA: las filas de lo ya sembrado se conservan como rastro de auditoría.\n")
for fila in sorted(previas.values()):
fh.write("\t".join(fila) + "\n")
print(f"==> resueltas con evidencia del tarball: {len(filas)}")
print(f"==> resueltas con evidencia de la fuente: {len(filas)}")
for n, s, c in filas:
print(f" {n:28s} {s:34s} {c[:70]}")
print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")