Las 33 que quedan sólo existían en la salida del script, o sea que se perdían al cerrar la terminal. Para quien tiene que decidirlas —y son decisiones legales, no mecánicas— «qué falta y POR QUÉ no se puede afirmar» es tan dato como los veredictos. `docs/licencias-pendientes.tsv` las deja listadas con su motivo: cuáles traen varios ficheros de licencia que dicen cosas distintas (gmp, ffmpeg, clang18, llvm18, los `gi-*`), cuáles tienen un LICENSE que no reconozco y con qué frase empieza (fuse3, lsof), y cuáles no traen nada en el árbol (boost, pigz, sqlite-shared). Al revés que `licencias-evidencia.tsv`, ésta se REGENERA entera en cada corrida. No es un registro de lo que se probó sino una foto de lo que queda, y una pendiente resuelta tiene que DESAPARECER de acá —si se acumulara, la lista de trabajo mentiría hacia arriba para siempre.
568 lines
28 KiB
Python
Executable File
568 lines
28 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""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`
|
||
resuelve `-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan
|
||
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. 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
|
||
normativo. No hay nada que interpretar.
|
||
2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero
|
||
ES el identificador SPDX.
|
||
3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes.
|
||
|
||
── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ────────────────────────────────────────────────────
|
||
El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la
|
||
concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option)
|
||
any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia
|
||
siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se
|
||
busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó
|
||
`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub.
|
||
|
||
── SE GUARDA LA CITA ───────────────────────────────────────────────────────────────────────────
|
||
Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal:
|
||
quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un
|
||
veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí.
|
||
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` 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-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
|
||
import sys
|
||
import glob
|
||
import shutil
|
||
import hashlib
|
||
import tarfile
|
||
import tempfile
|
||
import subprocess
|
||
import tomllib
|
||
|
||
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
CACHE = os.path.join(RAIZ, "work", "tarballs")
|
||
SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv")
|
||
COLA = os.path.join(RAIZ, "docs", "licencias-pendientes.tsv")
|
||
|
||
# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA
|
||
# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos
|
||
# según quién lo empaquete.
|
||
#
|
||
# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de
|
||
# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se
|
||
# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí:
|
||
# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula.
|
||
FAMILIAS = [
|
||
("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"),
|
||
("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"),
|
||
("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"),
|
||
("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"),
|
||
("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"),
|
||
("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"),
|
||
("Apache-2.0", "Apache License Version 2.0"),
|
||
("MPL-2.0", "Mozilla Public License Version 2.0"),
|
||
("CC0-1.0", "CC0 1.0 Universal"),
|
||
("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose "
|
||
"with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"),
|
||
("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose "
|
||
"with or without fee is hereby granted, provided that the above copyright notice"),
|
||
("Zlib", "altered source versions must be plainly marked as such"),
|
||
("MIT", "without limitation the rights to use, copy, modify, merge, publish"),
|
||
("BSD-4-Clause", "All advertising materials mentioning features or use of this software"),
|
||
("BSD-3-Clause", "endorse or promote products derived"),
|
||
("BSD-2-Clause", "Redistribution and use in source and binary forms"),
|
||
]
|
||
# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una.
|
||
ABSORBE = {
|
||
"BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"],
|
||
"BSD-3-Clause": ["BSD-2-Clause"],
|
||
"0BSD": ["ISC"],
|
||
"LGPL-3.0": ["GPL-3.0"],
|
||
"LGPL-2.1": ["GPL-2.0"],
|
||
"AGPL-3.0": ["GPL-3.0"],
|
||
}
|
||
# Excepciones que cambian el identificador y son invisibles al texto base de la licencia.
|
||
EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")]
|
||
# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later`
|
||
# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes.
|
||
AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"}
|
||
|
||
NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I)
|
||
# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera).
|
||
EXT_FUENTE = (
|
||
".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh",
|
||
".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4",
|
||
)
|
||
FRASE_LATER = re.compile(
|
||
r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later",
|
||
re.I,
|
||
)
|
||
FRASE_ONLY = re.compile(
|
||
r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only",
|
||
re.I,
|
||
)
|
||
def spdx_conocidos():
|
||
"""Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh).
|
||
|
||
Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación
|
||
al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no
|
||
tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta
|
||
nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es
|
||
bajar su texto, no relajar la validación.
|
||
|
||
Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía.
|
||
"""
|
||
d = os.path.join(RAIZ, "licenses")
|
||
if not os.path.isdir(d):
|
||
return set()
|
||
return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")}
|
||
|
||
|
||
CONOCIDOS = spdx_conocidos()
|
||
PEGAMENTO = {"AND", "OR", "WITH"}
|
||
|
||
|
||
def spdx_valido(expr):
|
||
"""Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto."""
|
||
if not CONOCIDOS:
|
||
return True # sin `licenses/` no hay con qué validar; no se inventa un veredicto
|
||
piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO]
|
||
return bool(piezas) and all(x in CONOCIDOS for x in piezas)
|
||
|
||
SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)")
|
||
|
||
# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda
|
||
# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su
|
||
# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor.
|
||
BOILERPLATE = re.compile(
|
||
r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|"
|
||
r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)"
|
||
)
|
||
# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA
|
||
# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es
|
||
# incoherente, y era exactamente el caso de nano.
|
||
VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I)
|
||
|
||
MAX_BYTES = 512 * 1024 # un fichero de fuente más grande que esto no es una cabecera de licencia
|
||
MAX_MIEMBROS = 6000 # cota para no recorrer árboles gigantes enteros
|
||
|
||
|
||
def recetas_sin_licencia():
|
||
out = []
|
||
for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) +
|
||
glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))):
|
||
try:
|
||
d = tomllib.load(open(f, "rb"))
|
||
except Exception:
|
||
continue
|
||
if "name" in d and not d.get("license"):
|
||
out.append((f, d))
|
||
return out
|
||
|
||
|
||
def sin_prefijo(nombre):
|
||
"""`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz."""
|
||
return nombre.split("/", 1)[1] if "/" in nombre else ""
|
||
|
||
|
||
def una_linea(txt, tope=160):
|
||
"""La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV
|
||
en varias, que es un fichero corrupto con aspecto de fichero bueno."""
|
||
t = " ".join(str(txt).split()).replace("\t", " ")
|
||
return t if len(t) <= tope else t[:tope - 1] + "…"
|
||
|
||
|
||
def concuerda(cita, mayor):
|
||
"""¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada."""
|
||
if not cita:
|
||
return False
|
||
m = VERSION_EN_FRASE.search(cita)
|
||
if not m:
|
||
return True # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita
|
||
return (m.group(1) or m.group(2) or m.group(3)) == mayor
|
||
|
||
|
||
def identificar(texto):
|
||
"""Todas las licencias presentes en un texto, ya colapsadas las que se contienen."""
|
||
# Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va
|
||
# dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products»,
|
||
# que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3.
|
||
t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n"))
|
||
t = " ".join(t.split())
|
||
hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t]
|
||
for mayor, menores in ABSORBE.items():
|
||
if mayor in hits:
|
||
hits = [h for h in hits if h not in menores]
|
||
for base, marca, con_excepcion in EXCEPCIONES:
|
||
if hits == [base] and marca in t:
|
||
hits = [con_excepcion]
|
||
return hits
|
||
|
||
|
||
def declaracion_del_autor(ruta, texto):
|
||
"""El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera."""
|
||
base = os.path.basename(ruta)
|
||
if base == "Cargo.toml":
|
||
m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M)
|
||
if m:
|
||
return m.group(1), f"{ruta}: license = \"{m.group(1)}\""
|
||
if base == "meson.build":
|
||
m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto)
|
||
if m:
|
||
return m.group(1), f"{ruta}: license: '{m.group(1)}'"
|
||
return None, None
|
||
|
||
|
||
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 sí (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
|
||
no_reconocido = 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
|
||
familia = None
|
||
familia_fichero = None
|
||
varias = None
|
||
cita_later = None
|
||
cita_only = None
|
||
spdx_headers = {}
|
||
|
||
try:
|
||
for rel, datos in miembros:
|
||
base = os.path.basename(rel)
|
||
|
||
if rel.startswith("LICENSES/"):
|
||
reuse.append(base)
|
||
continue
|
||
|
||
# ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
|
||
# Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
|
||
# del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
|
||
# veredicto del contenedor.
|
||
if "/" in rel:
|
||
es_licencia_raiz = False
|
||
else:
|
||
es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
|
||
|
||
interesa_autor = rel in ("Cargo.toml", "meson.build")
|
||
es_licencia = es_licencia_raiz
|
||
es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
|
||
if not (interesa_autor or es_licencia or es_fuente):
|
||
continue
|
||
|
||
if interesa_autor and autor is None:
|
||
lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
|
||
if lic:
|
||
autor = (lic, cita)
|
||
|
||
if es_licencia:
|
||
# `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
|
||
# una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
|
||
# es BSD — quedarse con el primero que aparezca lo declaraba BSD.
|
||
exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
|
||
candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
|
||
continue # NUNCA se busca la concesión dentro del propio COPYING
|
||
|
||
if es_fuente and BOILERPLATE.match(rel):
|
||
es_fuente = False
|
||
|
||
if es_fuente:
|
||
h = SPDX_HDR.search(datos)
|
||
if h:
|
||
ident = h.group(1).decode().strip()
|
||
spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
|
||
texto = datos.decode("utf-8", "replace")
|
||
if cita_later is None:
|
||
mm = FRASE_LATER.search(texto)
|
||
if mm:
|
||
cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
|
||
if cita_only is None:
|
||
mm = FRASE_ONLY.search(texto)
|
||
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`,
|
||
# y los dos dicen BSD-3. Ambiguo es que digan COSAS DISTINTAS.
|
||
#
|
||
# Probé antes con «el `COPYING` a secas gana al sufijado»: arregla socat (cuyo
|
||
# `COPYING.OpenSSL` es una excepción) y ROMPE gmp, que junto a `COPYING` (GPLv3) trae
|
||
# `COPYING.LESSERv3` porque la biblioteca es LGPL — el veredicto salía GPL-3.0-or-later, que
|
||
# es falso. La diferencia entre «el extra nombra una dep» y «el extra nombra otra versión»
|
||
# es demasiado fina para codificarla sin equivocarse, y equivocarse acá es afirmar algo que
|
||
# no es. ffmpeg es el caso límite: sus cuatro COPYING.* los desempatan los FLAGS de la
|
||
# receta (`--enable-gpl`), o sea que la respuesta ni siquiera está en el árbol.
|
||
vistos = {}
|
||
for _, ruta, texto in sorted(candidatos):
|
||
for h in identificar(texto):
|
||
vistos.setdefault(h, ruta)
|
||
if len(vistos) == 1:
|
||
familia, familia_fichero = next(iter(vistos.items()))
|
||
elif len(vistos) > 1:
|
||
return None, ("la raíz dice " + str(len(vistos)) + " licencias distintas ("
|
||
+ ", ".join(f"{k} en {v}" for k, v in sorted(vistos.items()))
|
||
+ "): la expresión la compone un humano")
|
||
else:
|
||
# Hay fichero de licencia y NO se reconoce su texto. Es un caso DISTINTO de «no hay
|
||
# fichero», y se acciona distinto: uno pide una huella nueva, el otro pide buscar la
|
||
# licencia en otro sitio. Decir lo mismo de los dos mandaba a mirar donde no era.
|
||
_, familia_fichero, texto = sorted(candidatos)[0]
|
||
primera = next((l.strip() for l in texto.splitlines() if l.strip()), "")
|
||
no_reconocido = f'{familia_fichero} existe pero no reconozco su texto — empieza: "{primera[:70]}"'
|
||
|
||
# 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
|
||
# `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
|
||
# basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
|
||
if autor:
|
||
if spdx_valido(autor[0]):
|
||
return autor
|
||
return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
|
||
f"en licenses/ ({autor[1]})")
|
||
|
||
# 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
|
||
únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
|
||
if len(únicos) == 1:
|
||
return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
|
||
|
||
# 3. Texto de la licencia + concesión en las cabeceras.
|
||
if familia:
|
||
if familia not in AMBIGUAS:
|
||
return familia, f"{familia_fichero} (texto de {familia})"
|
||
mayor = familia.rsplit("-", 1)[1][0] # «GPL-3.0» → «3»
|
||
if not concuerda(cita_later, mayor):
|
||
cita_later = None
|
||
if not concuerda(cita_only, mayor):
|
||
cita_only = None
|
||
if cita_later and not cita_only:
|
||
return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
|
||
if cita_only and not cita_later:
|
||
return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
|
||
pista = f"{familia} ambigua"
|
||
if cita_later and cita_only:
|
||
pista += " · el árbol dice las DOS cosas, decide un humano"
|
||
else:
|
||
pista += " · sin concesión fuera del COPYING"
|
||
return None, pista
|
||
|
||
if varias:
|
||
return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
|
||
+ ", ".join(varias) + "): la expresión la compone un humano")
|
||
if únicos:
|
||
return None, "REUSE con varias licencias: " + ",".join(únicos)
|
||
if no_reconocido:
|
||
return None, no_reconocido
|
||
if spdx_headers:
|
||
top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
|
||
return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
|
||
return None, "sin COPYING ni declaración en el árbol"
|
||
|
||
|
||
def bajar_verificado(url, sha, destino):
|
||
"""Baja `url` a `destino` y confirma su sha256. Devuelve el porqué si no se pudo."""
|
||
r = subprocess.run(
|
||
["curl", "-fsSL", "--max-time", "180", "-o", destino, url],
|
||
capture_output=True, text=True,
|
||
)
|
||
if r.returncode != 0:
|
||
return f"curl falló ({r.returncode}): {r.stderr.strip()[:80]}"
|
||
h = hashlib.sha256()
|
||
with open(destino, "rb") as fh:
|
||
for bloque in iter(lambda: fh.read(1 << 20), b""):
|
||
h.update(bloque)
|
||
if h.hexdigest() != sha:
|
||
return f"sha256 NO coincide: bajó {h.hexdigest()[:16]}…, la receta pinea {sha[:16]}…"
|
||
return None
|
||
|
||
|
||
def main():
|
||
args = [a for a in sys.argv[1:]]
|
||
con_red = "--fetch" in args
|
||
args = [a for a in args if a != "--fetch"]
|
||
objetivo = args[0] if args else None
|
||
tmp = tempfile.mkdtemp(prefix="licencias-") if con_red else None
|
||
filas, pendientes, sin_tar = [], [], []
|
||
|
||
for f, d in recetas_sin_licencia():
|
||
nombre = d["name"]
|
||
if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
|
||
continue
|
||
src = d.get("source", {})
|
||
sha = src.get("sha256")
|
||
if not sha:
|
||
# ── 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
|
||
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
|
||
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:
|
||
pendientes.append((nombre, una_linea(cita)))
|
||
|
||
if tmp:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
if objetivo:
|
||
for n, s, c in filas:
|
||
print(f"{n}\t{s}\n evidencia: {c}")
|
||
for n, c in pendientes + sin_tar:
|
||
print(f"{n}\tPENDIENTE — {una_linea(c)}")
|
||
return
|
||
|
||
# ACUMULATIVO, no regenerado: una receta ya sembrada sale del conjunto de entrada (porque ya
|
||
# tiene `license`), así que reescribir el fichero entero borraría su cita. Y la cita es el
|
||
# rastro de auditoría — lo único que deja revisar un veredicto sin repetir el trabajo. Un
|
||
# fichero de evidencia que se olvida de lo que ya probó no es evidencia.
|
||
previas = {}
|
||
if os.path.exists(SALIDA):
|
||
for linea in open(SALIDA):
|
||
if linea.startswith("#") or not linea.strip():
|
||
continue
|
||
partes = linea.rstrip("\n").split("\t")
|
||
if len(partes) == 3:
|
||
previas[partes[0]] = tuple(partes)
|
||
for n, sp, c in filas:
|
||
previas[n] = (n, sp, c)
|
||
|
||
with open(SALIDA, "w") as fh:
|
||
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 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)}")
|
||
for n, c in pendientes:
|
||
print(f" {n:28s} {c}")
|
||
print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
|
||
for n, c in sin_tar:
|
||
print(f" {n:28s} {c}")
|
||
# La cola de lo que NO se pudo afirmar también se escribe. Vivía sólo en la salida del script,
|
||
# o sea que se perdía al cerrar la terminal: para el humano que tiene que decidir, «lo que falta
|
||
# y por qué» es tan dato como los veredictos. Ésta SÍ se regenera entera en cada corrida —al
|
||
# revés que la de evidencia, que acumula— porque es una foto de lo que queda, no un registro de
|
||
# lo que se probó: una pendiente resuelta tiene que DESAPARECER de acá.
|
||
with open(COLA, "w") as fh:
|
||
fh.write("# licencias-pendientes.tsv — lo que NO alcanza para afirmar una licencia, y por qué.\n")
|
||
fh.write("# Lo regenera entero scripts/licencias-fuente.py en cada corrida: es la foto de lo\n")
|
||
fh.write("# que queda, no un registro. Los veredictos van en licencias-evidencia.tsv.\n")
|
||
fh.write("#\n# paquete<TAB>por qué no se puede afirmar\n")
|
||
for n, c in sorted(set(pendientes)) + sorted(set(sin_tar)):
|
||
fh.write(f"{n}\t{una_linea(c)}\n")
|
||
|
||
print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} y {os.path.relpath(COLA, RAIZ)}"
|
||
f" · NADA se tocó en recipes/")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|