tawasuyu

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…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

⎇ 59a0e56a16349d0d7c7c0de4555120891f6c1822 scripts/licencias-fuente.py Python · 552 lines · 26.8 KB History Who touched what raw

licencias 97% (1132/1165): «no hay fichero» y «no reconozco el texto» no son lo mismo 59a0e56a1 · Sergio ·

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    no_reconocido = None
293    reuse = []            # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX)
294    candidatos = []       # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ
295    familia = None
296    familia_fichero = None
297    varias = None
298    cita_later = None
299    cita_only = None
300    spdx_headers = {}
301
302    try:
303        for rel, datos in miembros:
304            base = os.path.basename(rel)
305
306            if rel.startswith("LICENSES/"):
307                reuse.append(base)
308                continue
309
310            # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es
311            # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la
312            # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el
313            # veredicto del contenedor.
314            if "/" in rel:
315                es_licencia_raiz = False
316            else:
317                es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None
318
319            interesa_autor = rel in ("Cargo.toml", "meson.build")
320            es_licencia = es_licencia_raiz
321            es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS")
322            if not (interesa_autor or es_licencia or es_fuente):
323                continue
324
325            if interesa_autor and autor is None:
326                lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace"))
327                if lic:
328                    autor = (lic, cita)
329
330            if es_licencia:
331                # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o
332                # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL`
333                # es BSD — quedarse con el primero que aparezca lo declaraba BSD.
334                exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None
335                candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace")))
336                continue  # NUNCA se busca la concesión dentro del propio COPYING
337
338            if es_fuente and BOILERPLATE.match(rel):
339                es_fuente = False
340
341            if es_fuente:
342                h = SPDX_HDR.search(datos)
343                if h:
344                    ident = h.group(1).decode().strip()
345                    spdx_headers[ident] = spdx_headers.get(ident, 0) + 1
346                texto = datos.decode("utf-8", "replace")
347                if cita_later is None:
348                    mm = FRASE_LATER.search(texto)
349                    if mm:
350                        cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
351                if cita_only is None:
352                    mm = FRASE_ONLY.search(texto)
353                    if mm:
354                        cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…"
355
356    except RuntimeError as e:
357        return None, str(e)
358
359    if candidatos:
360        # Se identifica CADA fichero de la raíz y se unen los resultados. Que haya varios no es
361        # ambigüedad por sí solo: el `COPYING` de pcre2 son dos líneas que apuntan a `LICENCE.md`,
362        # y los dos dicen BSD-3. Ambiguo es que digan COSAS DISTINTAS.
363        #
364        # Probé antes con «el `COPYING` a secas gana al sufijado»: arregla socat (cuyo
365        # `COPYING.OpenSSL` es una excepción) y ROMPE gmp, que junto a `COPYING` (GPLv3) trae
366        # `COPYING.LESSERv3` porque la biblioteca es LGPL — el veredicto salía GPL-3.0-or-later, que
367        # es falso. La diferencia entre «el extra nombra una dep» y «el extra nombra otra versión»
368        # es demasiado fina para codificarla sin equivocarse, y equivocarse acá es afirmar algo que
369        # no es. ffmpeg es el caso límite: sus cuatro COPYING.* los desempatan los FLAGS de la
370        # receta (`--enable-gpl`), o sea que la respuesta ni siquiera está en el árbol.
371        vistos = {}
372        for _, ruta, texto in sorted(candidatos):
373            for h in identificar(texto):
374                vistos.setdefault(h, ruta)
375        if len(vistos) == 1:
376            familia, familia_fichero = next(iter(vistos.items()))
377        elif len(vistos) > 1:
378            return None, ("la raíz dice " + str(len(vistos)) + " licencias distintas ("
379                          + ", ".join(f"{k} en {v}" for k, v in sorted(vistos.items()))
380                          + "): la expresión la compone un humano")
381        else:
382            # Hay fichero de licencia y NO se reconoce su texto. Es un caso DISTINTO de «no hay
383            # fichero», y se acciona distinto: uno pide una huella nueva, el otro pide buscar la
384            # licencia en otro sitio. Decir lo mismo de los dos mandaba a mirar donde no era.
385            _, familia_fichero, texto = sorted(candidatos)[0]
386            primera = next((l.strip() for l in texto.splitlines() if l.strip()), "")
387            no_reconocido = f'{familia_fichero} existe pero no reconozco su texto — empieza: "{primera[:70]}"' 
388
389    # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y
390    # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter
391    # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista.
392    if autor:
393        if spdx_valido(autor[0]):
394            return autor
395        return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto "
396                      f"en licenses/ ({autor[1]})")
397
398    # 2. REUSE con un único fichero de licencia: el nombre es el SPDX.
399    únicos = sorted(set(os.path.splitext(x)[0] for x in reuse))
400    if len(únicos) == 1:
401        return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)"
402
403    # 3. Texto de la licencia + concesión en las cabeceras.
404    if familia:
405        if familia not in AMBIGUAS:
406            return familia, f"{familia_fichero} (texto de {familia})"
407        mayor = familia.rsplit("-", 1)[1][0]         # «GPL-3.0» → «3»
408        if not concuerda(cita_later, mayor):
409            cita_later = None
410        if not concuerda(cita_only, mayor):
411            cita_only = None
412        if cita_later and not cita_only:
413            return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}"
414        if cita_only and not cita_later:
415            return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}"
416        pista = f"{familia} ambigua"
417        if cita_later and cita_only:
418            pista += " · el árbol dice las DOS cosas, decide un humano"
419        else:
420            pista += " · sin concesión fuera del COPYING"
421        return None, pista
422
423    if varias:
424        return None, (f"{familia_fichero} mezcla {len(varias)} licencias ("
425                      + ", ".join(varias) + "): la expresión la compone un humano")
426    if únicos:
427        return None, "REUSE con varias licencias: " + ",".join(únicos)
428    if no_reconocido:
429        return None, no_reconocido
430    if spdx_headers:
431        top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3]
432        return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top)
433    return None, "sin COPYING ni declaración en el árbol"
434
435
436def bajar_verificado(url, sha, destino):
437    """Baja `url` a `destino` y confirma su sha256. Devuelve el porqué si no se pudo."""
438    r = subprocess.run(
439        ["curl", "-fsSL", "--max-time", "180", "-o", destino, url],
440        capture_output=True, text=True,
441    )
442    if r.returncode != 0:
443        return f"curl falló ({r.returncode}): {r.stderr.strip()[:80]}"
444    h = hashlib.sha256()
445    with open(destino, "rb") as fh:
446        for bloque in iter(lambda: fh.read(1 << 20), b""):
447            h.update(bloque)
448    if h.hexdigest() != sha:
449        return f"sha256 NO coincide: bajó {h.hexdigest()[:16]}…, la receta pinea {sha[:16]}…"
450    return None
451
452
453def main():
454    args = [a for a in sys.argv[1:]]
455    con_red = "--fetch" in args
456    args = [a for a in args if a != "--fetch"]
457    objetivo = args[0] if args else None
458    tmp = tempfile.mkdtemp(prefix="licencias-") if con_red else None
459    filas, pendientes, sin_tar = [], [], []
460
461    for f, d in recetas_sin_licencia():
462        nombre = d["name"]
463        if objetivo and nombre != objetivo and os.path.basename(f) != objetivo:
464            continue
465        src = d.get("source", {})
466        sha = src.get("sha256")
467        if not sha:
468            # ── Fuente git: el commit pineado ES la evidencia, igual que un sha256 ──────────────
469            repo, commit = src.get("repo"), src.get("commit")
470            if not (repo and commit):
471                sin_tar.append((nombre, "fuente sin tarball ni repo/commit"))
472                continue
473            if repo.startswith(("ssh://", "git@")):
474                # El fetch de este script es anónimo; un repo por SSH necesita claves que no tiene.
475                sin_tar.append((nombre, "repo por SSH: este script no autentica"))
476                continue
477            if not con_red:
478                sin_tar.append((nombre, "fuente git (probá --fetch)"))
479                continue
480            sub = os.path.join(tmp, nombre + "-git")
481            os.makedirs(sub, exist_ok=True)
482            try:
483                spdx, cita = analizar(miembros_de_git(repo, commit, sub))
484            except RuntimeError as e:
485                sin_tar.append((nombre, str(e)))
486                continue
487        else:
488            tar_path = os.path.join(CACHE, sha + ".tar")
489            if not os.path.exists(tar_path):
490                url = src.get("tarball")
491                if not (con_red and url):
492                    sin_tar.append((nombre, "tarball no cacheado"
493                                    + ("" if con_red else " (probá --fetch)")))
494                    continue
495                tar_path = os.path.join(tmp, sha)
496                fallo = bajar_verificado(url, sha, tar_path)
497                if fallo:
498                    sin_tar.append((nombre, fallo))
499                    continue
500            spdx, cita = analizar(miembros_de_tar(tar_path))
501        if spdx:
502            filas.append((nombre, spdx, una_linea(cita)))
503        else:
504            pendientes.append((nombre, una_linea(cita)))
505
506    if tmp:
507        shutil.rmtree(tmp, ignore_errors=True)
508
509    if objetivo:
510        for n, s, c in filas:
511            print(f"{n}\t{s}\n   evidencia: {c}")
512        for n, c in pendientes + sin_tar:
513            print(f"{n}\tPENDIENTE — {una_linea(c)}")
514        return
515
516    # ACUMULATIVO, no regenerado: una receta ya sembrada sale del conjunto de entrada (porque ya
517    # tiene `license`), así que reescribir el fichero entero borraría su cita. Y la cita es el
518    # rastro de auditoría — lo único que deja revisar un veredicto sin repetir el trabajo. Un
519    # fichero de evidencia que se olvida de lo que ya probó no es evidencia.
520    previas = {}
521    if os.path.exists(SALIDA):
522        for linea in open(SALIDA):
523            if linea.startswith("#") or not linea.strip():
524                continue
525            partes = linea.rstrip("\n").split("\t")
526            if len(partes) == 3:
527                previas[partes[0]] = tuple(partes)
528    for n, sp, c in filas:
529        previas[n] = (n, sp, c)
530
531    with open(SALIDA, "w") as fh:
532        fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-fuente.py desde el\n")
533        fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n")
534        fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n")
535        fh.write("# Se ACUMULA: las filas de lo ya sembrado se conservan como rastro de auditoría.\n")
536        for fila in sorted(previas.values()):
537            fh.write("\t".join(fila) + "\n")
538
539    print(f"==> resueltas con evidencia de la fuente: {len(filas)}")
540    for n, s, c in filas:
541        print(f"   {n:28s} {s:34s} {c[:70]}")
542    print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}")
543    for n, c in pendientes:
544        print(f"   {n:28s} {c}")
545    print(f"\n==> sin tarball que mirar: {len(sin_tar)}")
546    for n, c in sin_tar:
547        print(f"   {n:28s} {c}")
548    print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} · NADA se tocó en recipes/")
549
550
551if __name__ == "__main__":
552    main()