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…
1#!/usr/bin/env python3
2"""licencias-fuente.py — deduce el SPDX de una receta desde SU PROPIA fuente pineada.
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. Y para las recetas de fuente
12git, el `commit` pineado cumple el mismo papel que el sha256: se lee sin clonar el árbol entero.
13
14── LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL ────────────────────────────────────────
15 1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato
16 normativo. No hay nada que interpretar.
17 2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero
18 ES el identificador SPDX.
19 3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes.
20
21── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ────────────────────────────────────────────────────
22El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la
23concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option)
24any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia
25siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se
26busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó
27`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub.
28
29── SE GUARDA LA CITA ───────────────────────────────────────────────────────────────────────────
30Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal:
31quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un
32veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí.
33NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`.
34
35── EL MODO `--fetch` ───────────────────────────────────────────────────────────────────────────
36Por defecto NO toca la red: mira sólo lo que ya está en `work/tarballs/`. Con `--fetch` trae a un
37temporal lo que falte —tarballs y fuentes git—, verifica el tarball por sha256 —el mismo sha que la receta pinea, así
38que o es el árbol exacto o no se mira— y los borra al terminar. No escribe en la caché de hammer a
39propósito: la caché es suya y un fichero puesto ahí por otro camino es una vía de envenenamiento
40que nadie audita.
41
42Uso: scripts/licencias-fuente.py informe + escribe docs/licencias-evidencia.tsv
43 scripts/licencias-fuente.py --fetch además trae las fuentes que falten (red)
44 scripts/licencias-fuente.py <receta> una sola, con el detalle a la vista
45"""
46import os
47import re
48import sys
49import glob
50import shutil
51import hashlib
52import tarfile
53import tempfile
54import subprocess
55import tomllib
56
57RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
58CACHE = os.path.join(RAIZ, "work", "tarballs")
59SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv")
60
61# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA
62# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos
63# según quién lo empaquete.
64#
65# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de
66# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se
67# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí:
68# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula.
69FAMILIAS = [
70 ("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"),
71 ("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"),
72 ("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"),
73 ("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"),
74 ("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"),
75 ("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"),
76 ("Apache-2.0", "Apache License Version 2.0"),
77 ("MPL-2.0", "Mozilla Public License Version 2.0"),
78 ("CC0-1.0", "CC0 1.0 Universal"),
79 ("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose "
80 "with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"),
81 ("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose "
82 "with or without fee is hereby granted, provided that the above copyright notice"),
83 ("Zlib", "altered source versions must be plainly marked as such"),
84 ("MIT", "without limitation the rights to use, copy, modify, merge, publish"),
85 ("BSD-4-Clause", "All advertising materials mentioning features or use of this software"),
86 ("BSD-3-Clause", "endorse or promote products derived"),
87 ("BSD-2-Clause", "Redistribution and use in source and binary forms"),
88]
89# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una.
90ABSORBE = {
91 "BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"],
92 "BSD-3-Clause": ["BSD-2-Clause"],
93 "0BSD": ["ISC"],
94 "LGPL-3.0": ["GPL-3.0"],
95 "LGPL-2.1": ["GPL-2.0"],
96 "AGPL-3.0": ["GPL-3.0"],
97}
98# Excepciones que cambian el identificador y son invisibles al texto base de la licencia.
99EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")]
100# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later`
101# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes.
102AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"}
103
104NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I)
105# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera).
106EXT_FUENTE = (
107 ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh",
108 ".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4",
109)
110FRASE_LATER = re.compile(
111 r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later",
112 re.I,
113)
114FRASE_ONLY = re.compile(
115 r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only",
116 re.I,
117)
118def spdx_conocidos():
119 """Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh).
120
121 Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación
122 al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no
123 tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta
124 nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es
125 bajar su texto, no relajar la validación.
126
127 Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía.
128 """
129 d = os.path.join(RAIZ, "licenses")
130 if not os.path.isdir(d):
131 return set()
132 return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")}
133
134
135CONOCIDOS = spdx_conocidos()
136PEGAMENTO = {"AND", "OR", "WITH"}
137
138
139def spdx_valido(expr):
140 """Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto."""
141 if not CONOCIDOS:
142 return True # sin `licenses/` no hay con qué validar; no se inventa un veredicto
143 piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO]
144 return bool(piezas) and all(x in CONOCIDOS for x in piezas)
145
146SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)")
147
148# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda
149# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su
150# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor.
151BOILERPLATE = re.compile(
152 r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|"
153 r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)"
154)
155# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA
156# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es
157# incoherente, y era exactamente el caso de nano.
158VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I)
159
160MAX_BYTES = 512 * 1024 # un fichero de fuente más grande que esto no es una cabecera de licencia
161MAX_MIEMBROS = 6000 # cota para no recorrer árboles gigantes enteros
162
163
164def recetas_sin_licencia():
165 out = []
166 for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) +
167 glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))):
168 try:
169 d = tomllib.load(open(f, "rb"))
170 except Exception:
171 continue
172 if "name" in d and not d.get("license"):
173 out.append((f, d))
174 return out
175
176
177def sin_prefijo(nombre):
178 """`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz."""
179 return nombre.split("/", 1)[1] if "/" in nombre else ""
180
181
182def una_linea(txt, tope=160):
183 """La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV
184 en varias, que es un fichero corrupto con aspecto de fichero bueno."""
185 t = " ".join(str(txt).split()).replace("\t", " ")
186 return t if len(t) <= tope else t[:tope - 1] + "…"
187
188
189def concuerda(cita, mayor):
190 """¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada."""
191 if not cita:
192 return False
193 m = VERSION_EN_FRASE.search(cita)
194 if not m:
195 return True # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita
196 return (m.group(1) or m.group(2) or m.group(3)) == mayor
197
198
199def identificar(texto):
200 """Todas las licencias presentes en un texto, ya colapsadas las que se contienen."""
201 # Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va
202 # dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products»,
203 # que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3.
204 t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n"))
205 t = " ".join(t.split())
206 hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t]
207 for mayor, menores in ABSORBE.items():
208 if mayor in hits:
209 hits = [h for h in hits if h not in menores]
210 for base, marca, con_excepcion in EXCEPCIONES:
211 if hits == [base] and marca in t:
212 hits = [con_excepcion]
213 return hits
214
215
216def declaracion_del_autor(ruta, texto):
217 """El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera."""
218 base = os.path.basename(ruta)
219 if base == "Cargo.toml":
220 m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M)
221 if m:
222 return m.group(1), f"{ruta}: license = \"{m.group(1)}\""
223 if base == "meson.build":
224 m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto)
225 if m:
226 return m.group(1), f"{ruta}: license: '{m.group(1)}'"
227 return None, None
228
229
230def miembros_de_tar(tar_path):
231 """(ruta relativa a la raíz, bytes) de cada fichero del tar. Un solo recorrido, en streaming."""
232 try:
233 tf = tarfile.open(tar_path, "r:*")
234 except Exception as e:
235 raise RuntimeError(f"no se pudo abrir el tar: {e}")
236 with tf:
237 for i, m in enumerate(tf):
238 if i > MAX_MIEMBROS:
239 break
240 if not m.isfile() or m.size > MAX_BYTES:
241 continue
242 rel = sin_prefijo(m.name)
243 if not rel:
244 continue
245 try:
246 yield rel, tf.extractfile(m).read()
247 except Exception:
248 continue
249
250
251def miembros_de_git(repo, commit, tmp):
252 """Igual, pero desde un repo git, SIN clonar el árbol entero.
253
254 `--depth 1 --filter=blob:limit=64k` sobre el commit PINEADO: trae la historia de un solo commit
255 y sólo los blobs chicos. Un COPYING nunca pasa de 64k, y los fuentes donde vive la concesión
256 tampoco — los que sí (binarios, assets) son justo los que no interesan. Así una receta cuyo
257 árbol pesa cientos de megas se resuelve con unos pocos.
258 """
259 ejecutar = lambda *a: subprocess.run(a, cwd=tmp, capture_output=True, text=False)
260 subprocess.run(["git", "init", "-q", tmp], capture_output=True)
261 ejecutar("git", "remote", "add", "origin", repo)
262 r = ejecutar("git", "fetch", "-q", "--depth", "1", "--filter=blob:limit=64k", "origin", commit)
263 if r.returncode != 0:
264 raise RuntimeError("git fetch falló: "
265 + r.stderr.decode("utf-8", "replace").strip()[:100])
266 r = ejecutar("git", "ls-tree", "-r", "-z", "--long", "FETCH_HEAD")
267 if r.returncode != 0:
268 raise RuntimeError("git ls-tree falló")
269 n = 0
270 for reg in r.stdout.decode("utf-8", "replace").split("\0"):
271 if not reg or n > MAX_MIEMBROS:
272 break
273 meta, _, rel = reg.partition("\t")
274 campos = meta.split()
275 # <modo> <tipo> <sha> <tamaño>
276 if len(campos) < 4 or campos[1] != "blob":
277 continue
278 try:
279 if campos[3] != "-" and int(campos[3]) > MAX_BYTES:
280 continue
281 except ValueError:
282 pass
283 b = ejecutar("git", "cat-file", "blob", campos[2])
284 if b.returncode == 0:
285 n += 1
286 yield rel, b.stdout
287
288
289def analizar(miembros):
290 """Un solo recorrido de la fuente. Devuelve (spdx, cita) o (None, pista)."""
291 autor = None
292 reuse = [] # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
293 candidatos = [] # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
294 familia = None
295 familia_fichero = None
296 varias = None
297 cita_later = None
298 cita_only = None
299 spdx_headers = {}
300
301 try:
302 for rel, datos in miembros:
303 base = os.path.basename(rel)
304
305 if rel.startswith("LICENSES/"):
306 reuse.append(base)
307 continue
308
309 # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
310 # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
311 # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
312 # veredicto del contenedor.
313 if "/" in rel:
314 es_licencia_raiz = False
315 else:
316 es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
317
318 interesa_autor = rel in ("Cargo.toml", "meson.build")
319 es_licencia = es_licencia_raiz
320 es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
321 if not (interesa_autor or es_licencia or es_fuente):
322 continue
323
324 if interesa_autor and autor is None:
325 lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
326 if lic:
327 autor = (lic, cita)
328
329 if es_licencia:
330 # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
331 # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
332 # es BSD — quedarse con el primero que aparezca lo declaraba BSD.
333 exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
334 candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
335 continue # NUNCA se busca la concesión dentro del propio COPYING
336
337 if es_fuente and BOILERPLATE.match(rel):
338 es_fuente = False
339
340 if es_fuente:
341 h = SPDX_HDR.search(datos)
342 if h:
343 ident = h.group(1).decode().strip()
344 spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
345 texto = datos.decode("utf-8", "replace")
346 if cita_later is None:
347 mm = FRASE_LATER.search(texto)
348 if mm:
349 cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
350 if cita_only is None:
351 mm = FRASE_ONLY.search(texto)
352 if mm:
353 cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
354
355 except RuntimeError as e:
356 return None, str(e)
357
358 if candidatos:
359 # Se identifica CADA fichero de la raíz y se unen los resultados. Que haya varios no es
360 # ambigüedad por sí solo: el `COPYING` de pcre2 son dos líneas que apuntan a `LICENCE.md`,
361 # y los dos dicen BSD-3. Ambiguo es que digan COSAS DISTINTAS.
362 #
363 # Probé antes con «el `COPYING` a secas gana al sufijado»: arregla socat (cuyo
364 # `COPYING.OpenSSL` es una excepción) y ROMPE gmp, que junto a `COPYING` (GPLv3) trae
365 # `COPYING.LESSERv3` porque la biblioteca es LGPL — el veredicto salía GPL-3.0-or-later, que
366 # es falso. La diferencia entre «el extra nombra una dep» y «el extra nombra otra versión»
367 # es demasiado fina para codificarla sin equivocarse, y equivocarse acá es afirmar algo que
368 # no es. ffmpeg es el caso límite: sus cuatro COPYING.* los desempatan los FLAGS de la
369 # receta (`--enable-gpl`), o sea que la respuesta ni siquiera está en el árbol.
370 vistos = {}
371 for _, ruta, texto in sorted(candidatos):
372 for h in identificar(texto):
373 vistos.setdefault(h, ruta)
374 if len(vistos) == 1:
375 familia, familia_fichero = next(iter(vistos.items()))
376 elif len(vistos) > 1:
377 return None, ("la raíz dice " + str(len(vistos)) + " licencias distintas ("
378 + ", ".join(f"{k} en {v}" for k, v in sorted(vistos.items()))
379 + "): la expresión la compone un humano")
380 else:
381 familia_fichero = sorted(candidatos)[0][1]
382
383 # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
384 # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
385 # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
386 if autor:
387 if spdx_valido(autor[0]):
388 return autor
389 return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
390 f"en licenses/ ({autor[1]})")
391
392 # 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
393 únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
394 if len(únicos) == 1:
395 return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
396
397 # 3. Texto de la licencia + concesión en las cabeceras.
398 if familia:
399 if familia not in AMBIGUAS:
400 return familia, f"{familia_fichero} (texto de {familia})"
401 mayor = familia.rsplit("-", 1)[1][0] # «GPL-3.0» → «3»
402 if not concuerda(cita_later, mayor):
403 cita_later = None
404 if not concuerda(cita_only, mayor):
405 cita_only = None
406 if cita_later and not cita_only:
407 return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
408 if cita_only and not cita_later:
409 return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
410 pista = f"{familia} ambigua"
411 if cita_later and cita_only:
412 pista += " · el árbol dice las DOS cosas, decide un humano"
413 else:
414 pista += " · sin concesión fuera del COPYING"
415 return None, pista
416
417 if varias:
418 return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
419 + ", ".join(varias) + "): la expresión la compone un humano")
420 if únicos:
421 return None, "REUSE con varias licencias: " + ",".join(únicos)
422 if spdx_headers:
423 top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
424 return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
425 return None, "sin COPYING ni declaración en el árbol"
426
427
428def bajar_verificado(url, sha, destino):
429 """Baja `url` a `destino` y confirma su sha256. Devuelve el porqué si no se pudo."""
430 r = subprocess.run(
431 ["curl", "-fsSL", "--max-time", "180", "-o", destino, url],
432 capture_output=True, text=True,
433 )
434 if r.returncode != 0:
435 return f"curl falló ({r.returncode}): {r.stderr.strip()[:80]}"
436 h = hashlib.sha256()
437 with open(destino, "rb") as fh:
438 for bloque in iter(lambda: fh.read(1 << 20), b""):
439 h.update(bloque)
440 if h.hexdigest() != sha:
441 return f"sha256 NO coincide: bajó {h.hexdigest()[:16]}…, la receta pinea {sha[:16]}…"
442 return None
443
444
445def main():
446 args = [a for a in sys.argv[1:]]
447 con_red = "--fetch" in args
448 args = [a for a in args if a != "--fetch"]
449 objetivo = args[0] if args else None
450 tmp = tempfile.mkdtemp(prefix="licencias-") if con_red else None
451 filas, pendientes, sin_tar = [], [], []
452
453 for f, d in recetas_sin_licencia():
454 nombre = d["name"]
455 if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
456 continue
457 src = d.get("source", {})
458 sha = src.get("sha256")
459 if not sha:
460 # ── Fuente git: el commit pineado ES la evidencia, igual que un sha256 ──────────────
461 repo, commit = src.get("repo"), src.get("commit")
462 if not (repo and commit):
463 sin_tar.append((nombre, "fuente sin tarball ni repo/commit"))
464 continue
465 if repo.startswith(("ssh://", "git@")):
466 # El fetch de este script es anónimo; un repo por SSH necesita claves que no tiene.
467 sin_tar.append((nombre, "repo por SSH: este script no autentica"))
468 continue
469 if not con_red:
470 sin_tar.append((nombre, "fuente git (probá --fetch)"))
471 continue
472 sub = os.path.join(tmp, nombre + "-git")
473 os.makedirs(sub, exist_ok=True)
474 try:
475 spdx, cita = analizar(miembros_de_git(repo, commit, sub))
476 except RuntimeError as e:
477 sin_tar.append((nombre, str(e)))
478 continue
479 else:
480 tar_path = os.path.join(CACHE, sha + ".tar")
481 if not os.path.exists(tar_path):
482 url = src.get("tarball")
483 if not (con_red and url):
484 sin_tar.append((nombre, "tarball no cacheado"
485 + ("" if con_red else " (probá --fetch)")))
486 continue
487 tar_path = os.path.join(tmp, sha)
488 fallo = bajar_verificado(url, sha, tar_path)
489 if fallo:
490 sin_tar.append((nombre, fallo))
491 continue
492 spdx, cita = analizar(miembros_de_tar(tar_path))
493 if spdx:
494 filas.append((nombre, spdx, una_linea(cita)))
495 else:
496 pendientes.append((nombre, una_linea(cita)))
497
498 if tmp:
499 shutil.rmtree(tmp, ignore_errors=True)
500
501 if objetivo:
502 for n, s, c in filas:
503 print(f"{n}\t{s}\n evidencia: {c}")
504 for n, c in pendientes + sin_tar:
505 print(f"{n}\tPENDIENTE — {una_linea(c)}")
506 return
507
508 # ACUMULATIVO, no regenerado: una receta ya sembrada sale del conjunto de entrada (porque ya
509 # tiene `license`), así que reescribir el fichero entero borraría su cita. Y la cita es el
510 # rastro de auditoría — lo único que deja revisar un veredicto sin repetir el trabajo. Un
511 # fichero de evidencia que se olvida de lo que ya probó no es evidencia.
512 previas = {}
513 if os.path.exists(SALIDA):
514 for linea in open(SALIDA):
515 if linea.startswith("#") or not linea.strip():
516 continue
517 partes = linea.rstrip("\n").split("\t")
518 if len(partes) == 3:
519 previas[partes[0]] = tuple(partes)
520 for n, sp, c in filas:
521 previas[n] = (n, sp, c)
522
523 with open(SALIDA, "w") as fh:
524 fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-fuente.py desde el\n")
525 fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
526 fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
527 fh.write("# Se ACUMULA: las filas de lo ya sembrado se conservan como rastro de auditoría.\n")
528 for fila in sorted(previas.values()):
529 fh.write("\t".join(fila) + "\n")
530
531 print(f"==> resueltas con evidencia de la fuente: {len(filas)}")
532 for n, s, c in filas:
533 print(f" {n:28s} {s:34s} {c[:70]}")
534 print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")
535 for n, c in pendientes:
536 print(f" {n:28s} {c}")
537 print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
538 for n, c in sin_tar:
539 print(f" {n:28s} {c}")
540 print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} · NADA se tocó en recipes/")
541
542
543if __name__ == "__main__":
544 main()