gioser

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 815Rust 56Shell 37Markdown 291229 Definitions

⎇ ba1fb2d3a8ada748984dceeb0f58e6b3e67772b8 scripts/licencias-tarball.py Python · 410 lines · 19.7 KB History Who touched what raw

licencias: 95% (1114/1164) — la evidencia sale del tarball pineado, offline ba1fb2d3a · Sergio ·

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()