sergio/takana
> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…
⎇ ba1fb2d3a8ada748984dceeb0f58e6b3e67772b8
scripts/licencias-tarball.py
History
Who touched what
raw
licencias: 95% (1114/1164) — la evidencia sale del tarball pineado, offline
1#!/usr/bin/env python3
2"""licencias-tarball.py — deduce el SPDX de una receta desde SU PROPIO tarball pineado, offline.
3
4── QUÉ AÑADE SOBRE LOS HERMANOS ────────────────────────────────────────────────────────────────
5`licencias.sh --sembrar` escribe desde una tabla curada a mano, y `licencias-desambiguar.sh`
6resuelve `-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan
7fuera lo mismo: lo que no está en la tabla y lo que no vive en GitHub.
8
9Pero `work/tarballs/` guarda cada tarball indexado por su **sha256**, o sea que el árbol EXACTO que
10la receta pinea ya está en disco. La evidencia más fuerte posible —la declaración del propio autor
11en el commit que construimos— no necesita red: necesita abrir el tar.
12
13── LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL ────────────────────────────────────────
14 1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato
15 normativo. No hay nada que interpretar.
16 2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero
17 ES el identificador SPDX.
18 3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes.
19
20── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ────────────────────────────────────────────────────
21El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la
22concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option)
23any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia
24siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se
25busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó
26`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub.
27
28── SE GUARDA LA CITA ───────────────────────────────────────────────────────────────────────────
29Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal:
30quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un
31veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí.
32NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`.
33
34Uso: scripts/licencias-tarball.py informe + escribe docs/licencias-evidencia.tsv
35 scripts/licencias-tarball.py <receta> una sola, con el detalle a la vista
36"""
37import os
38import re
39import sys
40import glob
41import tarfile
42import tomllib
43
44RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
45CACHE = os.path.join(RAIZ, "work", "tarballs")
46SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv")
47
48# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA
49# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos
50# según quién lo empaquete.
51#
52# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de
53# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se
54# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí:
55# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula.
56FAMILIAS = [
57 ("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"),
58 ("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"),
59 ("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"),
60 ("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"),
61 ("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"),
62 ("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"),
63 ("Apache-2.0", "Apache License Version 2.0"),
64 ("MPL-2.0", "Mozilla Public License Version 2.0"),
65 ("CC0-1.0", "CC0 1.0 Universal"),
66 ("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose "
67 "with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"),
68 ("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose "
69 "with or without fee is hereby granted, provided that the above copyright notice"),
70 ("Zlib", "altered source versions must be plainly marked as such"),
71 ("MIT", "without limitation the rights to use, copy, modify, merge, publish"),
72 ("BSD-4-Clause", "All advertising materials mentioning features or use of this software"),
73 ("BSD-3-Clause", "endorse or promote products derived"),
74 ("BSD-2-Clause", "Redistribution and use in source and binary forms"),
75]
76# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una.
77ABSORBE = {
78 "BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"],
79 "BSD-3-Clause": ["BSD-2-Clause"],
80 "0BSD": ["ISC"],
81 "LGPL-3.0": ["GPL-3.0"],
82 "LGPL-2.1": ["GPL-2.0"],
83 "AGPL-3.0": ["GPL-3.0"],
84}
85# Excepciones que cambian el identificador y son invisibles al texto base de la licencia.
86EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")]
87# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later`
88# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes.
89AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"}
90
91NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I)
92# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera).
93EXT_FUENTE = (
94 ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh",
95 ".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4",
96)
97FRASE_LATER = re.compile(
98 r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later",
99 re.I,
100)
101FRASE_ONLY = re.compile(
102 r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only",
103 re.I,
104)
105def spdx_conocidos():
106 """Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh).
107
108 Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación
109 al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no
110 tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta
111 nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es
112 bajar su texto, no relajar la validación.
113
114 Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía.
115 """
116 d = os.path.join(RAIZ, "licenses")
117 if not os.path.isdir(d):
118 return set()
119 return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")}
120
121
122CONOCIDOS = spdx_conocidos()
123PEGAMENTO = {"AND", "OR", "WITH"}
124
125
126def spdx_valido(expr):
127 """Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto."""
128 if not CONOCIDOS:
129 return True # sin `licenses/` no hay con qué validar; no se inventa un veredicto
130 piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO]
131 return bool(piezas) and all(x in CONOCIDOS for x in piezas)
132
133SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)")
134
135# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda
136# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su
137# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor.
138BOILERPLATE = re.compile(
139 r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|"
140 r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)"
141)
142# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA
143# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es
144# incoherente, y era exactamente el caso de nano.
145VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I)
146
147MAX_BYTES = 512 * 1024 # un fichero de fuente más grande que esto no es una cabecera de licencia
148MAX_MIEMBROS = 6000 # cota para no recorrer árboles gigantes enteros
149
150
151def recetas_sin_licencia():
152 out = []
153 for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) +
154 glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))):
155 try:
156 d = tomllib.load(open(f, "rb"))
157 except Exception:
158 continue
159 if "name" in d and not d.get("license"):
160 out.append((f, d))
161 return out
162
163
164def sin_prefijo(nombre):
165 """`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz."""
166 return nombre.split("/", 1)[1] if "/" in nombre else ""
167
168
169def una_linea(txt, tope=160):
170 """La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV
171 en varias, que es un fichero corrupto con aspecto de fichero bueno."""
172 t = " ".join(str(txt).split()).replace("\t", " ")
173 return t if len(t) <= tope else t[:tope - 1] + "…"
174
175
176def concuerda(cita, mayor):
177 """¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada."""
178 if not cita:
179 return False
180 m = VERSION_EN_FRASE.search(cita)
181 if not m:
182 return True # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita
183 return (m.group(1) or m.group(2) or m.group(3)) == mayor
184
185
186def identificar(texto):
187 """Todas las licencias presentes en un texto, ya colapsadas las que se contienen."""
188 # Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va
189 # dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products»,
190 # que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3.
191 t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n"))
192 t = " ".join(t.split())
193 hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t]
194 for mayor, menores in ABSORBE.items():
195 if mayor in hits:
196 hits = [h for h in hits if h not in menores]
197 for base, marca, con_excepcion in EXCEPCIONES:
198 if hits == [base] and marca in t:
199 hits = [con_excepcion]
200 return hits
201
202
203def declaracion_del_autor(ruta, texto):
204 """El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera."""
205 base = os.path.basename(ruta)
206 if base == "Cargo.toml":
207 m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M)
208 if m:
209 return m.group(1), f"{ruta}: license = \"{m.group(1)}\""
210 if base == "meson.build":
211 m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto)
212 if m:
213 return m.group(1), f"{ruta}: license: '{m.group(1)}'"
214 return None, None
215
216
217def analizar(tar_path):
218 """Un solo recorrido del tar. Devuelve (spdx, cita) o (None, pista)."""
219 autor = None
220 reuse = [] # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
221 candidatos = [] # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
222 familia = None
223 familia_fichero = None
224 varias = None
225 cita_later = None
226 cita_only = None
227 spdx_headers = {}
228
229 try:
230 tf = tarfile.open(tar_path, "r:*")
231 except Exception as e:
232 return None, f"no se pudo abrir el tar: {e}"
233
234 with tf:
235 for i, m in enumerate(tf):
236 if i > MAX_MIEMBROS:
237 break
238 if not m.isfile() or m.size > MAX_BYTES:
239 continue
240 rel = sin_prefijo(m.name)
241 if not rel:
242 continue
243 base = os.path.basename(rel)
244
245 if rel.startswith("LICENSES/"):
246 reuse.append(base)
247 continue
248
249 # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
250 # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
251 # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
252 # veredicto del contenedor.
253 if "/" in rel:
254 es_licencia_raiz = False
255 else:
256 es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
257
258 interesa_autor = rel in ("Cargo.toml", "meson.build")
259 es_licencia = es_licencia_raiz
260 es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
261 if not (interesa_autor or es_licencia or es_fuente):
262 continue
263
264 try:
265 datos = tf.extractfile(m).read()
266 except Exception:
267 continue
268
269 if interesa_autor and autor is None:
270 lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
271 if lic:
272 autor = (lic, cita)
273
274 if es_licencia:
275 # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
276 # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
277 # es BSD — quedarse con el primero que aparezca lo declaraba BSD.
278 exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
279 candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
280 continue # NUNCA se busca la concesión dentro del propio COPYING
281
282 if es_fuente and BOILERPLATE.match(rel):
283 es_fuente = False
284
285 if es_fuente:
286 h = SPDX_HDR.search(datos)
287 if h:
288 ident = h.group(1).decode().strip()
289 spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
290 texto = datos.decode("utf-8", "replace")
291 if cita_later is None:
292 mm = FRASE_LATER.search(texto)
293 if mm:
294 cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
295 if cita_only is None:
296 mm = FRASE_ONLY.search(texto)
297 if mm:
298 cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
299
300 if candidatos:
301 exactos = [c for c in candidatos if c[0] == 0]
302 if not exactos and len(candidatos) > 1:
303 # ffmpeg trae COPYING.GPLv2, COPYING.GPLv3, COPYING.LGPLv2.1 y COPYING.LGPLv3 en la
304 # raíz: cuál rige lo deciden los FLAGS de la receta (`--enable-gpl`), no el árbol.
305 # Elegir el primero por orden alfabético sería inventar.
306 return None, ("la raíz trae " + str(len(candidatos)) + " ficheros de licencia y ninguno "
307 "canónico (" + ", ".join(sorted(c[1] for c in candidatos))
308 + "): decide la receta, no el árbol")
309 _, familia_fichero, texto = sorted(candidatos)[0]
310 hits = identificar(texto)
311 if len(hits) == 1:
312 familia = hits[0]
313 elif len(hits) > 1:
314 varias = hits
315
316 # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
317 # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
318 # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
319 if autor:
320 if spdx_valido(autor[0]):
321 return autor
322 return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
323 f"en licenses/ ({autor[1]})")
324
325 # 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
326 únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
327 if len(únicos) == 1:
328 return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
329
330 # 3. Texto de la licencia + concesión en las cabeceras.
331 if familia:
332 if familia not in AMBIGUAS:
333 return familia, f"{familia_fichero} (texto de {familia})"
334 mayor = familia.rsplit("-", 1)[1][0] # «GPL-3.0» → «3»
335 if not concuerda(cita_later, mayor):
336 cita_later = None
337 if not concuerda(cita_only, mayor):
338 cita_only = None
339 if cita_later and not cita_only:
340 return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
341 if cita_only and not cita_later:
342 return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
343 pista = f"{familia} ambigua"
344 if cita_later and cita_only:
345 pista += " · el árbol dice las DOS cosas, decide un humano"
346 else:
347 pista += " · sin concesión fuera del COPYING"
348 return None, pista
349
350 if varias:
351 return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
352 + ", ".join(varias) + "): la expresión la compone un humano")
353 if únicos:
354 return None, "REUSE con varias licencias: " + ",".join(únicos)
355 if spdx_headers:
356 top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
357 return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
358 return None, "sin COPYING ni declaración en el árbol"
359
360
361def main():
362 objetivo = sys.argv[1] if len(sys.argv) > 1 else None
363 filas, pendientes, sin_tar = [], [], []
364
365 for f, d in recetas_sin_licencia():
366 nombre = d["name"]
367 if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
368 continue
369 sha = d.get("source", {}).get("sha256")
370 if not sha:
371 sin_tar.append((nombre, "fuente git/dir: no hay tarball que mirar"))
372 continue
373 tar_path = os.path.join(CACHE, sha + ".tar")
374 if not os.path.exists(tar_path):
375 sin_tar.append((nombre, "tarball no cacheado"))
376 continue
377 spdx, cita = analizar(tar_path)
378 if spdx:
379 filas.append((nombre, spdx, una_linea(cita)))
380 else:
381 pendientes.append((nombre, una_linea(cita)))
382
383 if objetivo:
384 for n, s, c in filas:
385 print(f"{n}\t{s}\n evidencia: {c}")
386 for n, c in pendientes + sin_tar:
387 print(f"{n}\tPENDIENTE — {una_linea(c)}")
388 return
389
390 with open(SALIDA, "w") as fh:
391 fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el\n")
392 fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
393 fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
394 for n, s, c in filas:
395 fh.write(f"{n}\t{s}\t{c}\n")
396
397 print(f"==> resueltas con evidencia del tarball: {len(filas)}")
398 for n, s, c in filas:
399 print(f" {n:28s} {s:34s} {c[:70]}")
400 print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")
401 for n, c in pendientes:
402 print(f" {n:28s} {c}")
403 print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
404 for n, c in sin_tar:
405 print(f" {n:28s} {c}")
406 print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} · NADA se tocó en recipes/")
407
408
409if __name__ == "__main__":
410 main()