diff --git a/docs/licencias-evidencia.tsv b/docs/licencias-evidencia.tsv new file mode 100644 index 00000000..8824b860 --- /dev/null +++ b/docs/licencias-evidencia.tsv @@ -0,0 +1,29 @@ +# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el +# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo +# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado. +adwaita-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later' +dwarves GPL-2.0-only COPYING + concesión en btf_encoder.c: …/* SPDX-License-Identifier: GPL-2.0-only Copyright (C) 20… +elfutils-libdw GPL-3.0-or-later COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di… +elfutils GPL-3.0-or-later COPYING + concesión en tests/run-eu-search-die.sh: …ion; either version 3 of the License, or # (at your option) any later version. # # elfutils is di… +file BSD-2-Clause COPYING (texto de BSD-2-Clause) +go BSD-3-Clause LICENSE (texto de BSD-3-Clause) +gtk4-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later' +hammer-edit LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later' +dav1d BSD-2-Clause COPYING (texto de BSD-2-Clause) +exiv2 GPL-2.0-or-later COPYING + concesión en README.md: …ation; either version 2 of the License, or (at your option) any later version. Exiv2 program is… +intltool GPL-2.0-or-later COPYING + concesión en intltool-extract.in: …on; either version 2 of the # License, or (at your option) any later version. # # Intltool is d… +libpcap BSD-3-Clause LICENSE (texto de BSD-3-Clause) +libzip BSD-3-Clause LICENSE (texto de BSD-3-Clause) +mtdev MIT COPYING (texto de MIT) +json-c MIT COPYING (texto de MIT) +libsodium ISC LICENSE (texto de ISC) +libssh2 BSD-3-Clause COPYING (texto de BSD-3-Clause) +mesa-llvmpipe MIT meson.build: license: 'MIT' +mesa-swrast MIT meson.build: license: 'MIT' +mtdev MIT COPYING (texto de MIT) +nano GPL-3.0-or-later COPYING + concesión en NEWS: …Finally, nano is now licensed under the GNU GPL version 3 or later, and its documentat… +scdoc MIT COPYING (texto de MIT) +seatd MIT meson.build: license: 'MIT' +socat GPL-2.0-or-later COPYING + concesión en socat_buildscript_for_android.sh: …; either version 2.1 of the License, or (at your option) any later version. The GNU C Libr… +sourceview-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later' +tllist MIT meson.build: license: 'MIT' diff --git a/recipes/adwaita-hello.toml b/recipes/adwaita-hello.toml index 04b69ed5..86f6a696 100644 --- a/recipes/adwaita-hello.toml +++ b/recipes/adwaita-hello.toml @@ -3,6 +3,7 @@ # libadwaita-1 + las privadas de gdk/gsk/appstream que el .pc no declara. Mismo patrón que gtk4-hello. name = "adwaita-hello" version = "1.7.6" +license = "LGPL-2.1-or-later" [source] tarball = "https://download.gnome.org/sources/libadwaita/1.7/libadwaita-1.7.6.tar.xz" diff --git a/recipes/dwarves.toml b/recipes/dwarves.toml index a97730d7..fa6fc5fe 100644 --- a/recipes/dwarves.toml +++ b/recipes/dwarves.toml @@ -59,6 +59,7 @@ # name = "dwarves" version = "1.30" +license = "GPL-2.0-only" [source] tarball = "https://fedorapeople.org/~acme/dwarves/dwarves-1.30.tar.xz" diff --git a/recipes/elfutils-libdw.toml b/recipes/elfutils-libdw.toml index 6dbecded..389d553b 100644 --- a/recipes/elfutils-libdw.toml +++ b/recipes/elfutils-libdw.toml @@ -44,6 +44,7 @@ # name = "elfutils-libdw" version = "0.194" +license = "GPL-3.0-or-later" [source] tarball = "https://sourceware.org/elfutils/ftp/0.194/elfutils-0.194.tar.bz2" diff --git a/recipes/elfutils.toml b/recipes/elfutils.toml index 539f2254..d9fb5905 100644 --- a/recipes/elfutils.toml +++ b/recipes/elfutils.toml @@ -20,6 +20,7 @@ name = "elfutils" version = "0.194" +license = "GPL-3.0-or-later" [source] tarball = "https://sourceware.org/elfutils/ftp/0.194/elfutils-0.194.tar.bz2" diff --git a/recipes/file.toml b/recipes/file.toml index e638ea49..1d019af7 100644 --- a/recipes/file.toml +++ b/recipes/file.toml @@ -3,6 +3,7 @@ # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild. name = "file" version = "5.47" +license = "BSD-2-Clause" [source] tarball = "https://astron.com/pub/file/file-5.47.tar.gz" diff --git a/recipes/go.toml b/recipes/go.toml index 23bfde49..b0e7a532 100644 --- a/recipes/go.toml +++ b/recipes/go.toml @@ -9,6 +9,7 @@ # frente futuro, análogo a mrustc para Rust. name = "go" version = "1.26.4" +license = "BSD-3-Clause" [source] tarball = "https://go.dev/dl/go1.26.4.linux-amd64.tar.gz" diff --git a/recipes/gtk4-hello.toml b/recipes/gtk4-hello.toml index 017e29a5..4aef2cc5 100644 --- a/recipes/gtk4-hello.toml +++ b/recipes/gtk4-hello.toml @@ -4,6 +4,7 @@ # de GUI del lab es usable por apps, no solo "compila". deps = cierre .pc completo + gtk4. name = "gtk4-hello" version = "4.18.6" +license = "LGPL-2.1-or-later" [source] tarball = "https://download.gnome.org/sources/gtk/4.18/gtk-4.18.6.tar.xz" diff --git a/recipes/hammer-edit.toml b/recipes/hammer-edit.toml index 2bf5f9c4..086d3588 100644 --- a/recipes/hammer-edit.toml +++ b/recipes/hammer-edit.toml @@ -3,6 +3,7 @@ # estático (gtk4+libadwaita+gtksourceview). No es una demo: es una app instalable y usable. name = "hammer-edit" version = "1.0.0" +license = "LGPL-2.1-or-later" [source] tarball = "https://download.gnome.org/sources/gtksourceview/5.16/gtksourceview-5.16.0.tar.xz" diff --git a/recipes/incoming-cosmic/dav1d.toml b/recipes/incoming-cosmic/dav1d.toml index 8aabddbc..4f94535f 100644 --- a/recipes/incoming-cosmic/dav1d.toml +++ b/recipes/incoming-cosmic/dav1d.toml @@ -21,6 +21,7 @@ # etiqueta falsa — la deuda que el frente static-audit ya persiguió una vez. name = "dav1d" version = "1.5.4" +license = "BSD-2-Clause" [source] tarball = "https://code.videolan.org/videolan/dav1d/-/archive/1.5.4/dav1d-1.5.4.tar.gz" diff --git a/recipes/incoming-kde/exiv2.toml b/recipes/incoming-kde/exiv2.toml index 9ed90425..d26ad431 100644 --- a/recipes/incoming-kde/exiv2.toml +++ b/recipes/incoming-kde/exiv2.toml @@ -5,6 +5,7 @@ # runtime = libz.so (zlib-shared). Sin CLI ni samples. name = "exiv2" version = "0.28.5" +license = "GPL-2.0-or-later" [source] tarball = "https://github.com/Exiv2/exiv2/archive/refs/tags/v0.28.5.tar.gz" diff --git a/recipes/incoming-kde/intltool.toml b/recipes/incoming-kde/intltool.toml index bd7fd4c1..0f42cc63 100644 --- a/recipes/incoming-kde/intltool.toml +++ b/recipes/incoming-kde/intltool.toml @@ -3,6 +3,7 @@ # Autotools. Parche conocido: intltool 0.51 usa `\${\` en regex Perl que perl 5.26+ rechaza ⇒ sed fix. name = "intltool" version = "0.51.0" +license = "GPL-2.0-or-later" [source] tarball = "https://launchpad.net/intltool/trunk/0.51.0/+download/intltool-0.51.0.tar.gz" diff --git a/recipes/incoming-kde/libpcap.toml b/recipes/incoming-kde/libpcap.toml index b7205c27..474d546b 100644 --- a/recipes/incoming-kde/libpcap.toml +++ b/recipes/incoming-kde/libpcap.toml @@ -2,6 +2,7 @@ # flex/bison para el parser de filtros BPF. Backend PF_PACKET en Linux (sin libnl para minimizar). name = "libpcap" version = "1.10.5" +license = "BSD-3-Clause" [source] tarball = "https://www.tcpdump.org/release/libpcap-1.10.5.tar.xz" sha256 = "84fa89ac6d303028c1c5b754abff77224f45eca0a94eb1a34ff0aa9ceece3925" diff --git a/recipes/incoming-kde/libzip.toml b/recipes/incoming-kde/libzip.toml index 3bfd4a8c..0212e16c 100644 --- a/recipes/incoming-kde/libzip.toml +++ b/recipes/incoming-kde/libzip.toml @@ -5,6 +5,7 @@ # como .so dinámicos (bzip2-shared/xz-shared aportan el PIC); openssl para el cifrado AES del ZIP. name = "libzip" version = "1.11.3" +license = "BSD-3-Clause" [source] tarball = "https://libzip.org/download/libzip-1.11.3.tar.xz" diff --git a/recipes/incoming-kde/mtdev.toml b/recipes/incoming-kde/mtdev.toml index dbd98b9c..3ffcc130 100644 --- a/recipes/incoming-kde/mtdev.toml +++ b/recipes/incoming-kde/mtdev.toml @@ -4,6 +4,7 @@ # Dep de build de libinput (que kwin exige ≥1.28). Autotools. name = "mtdev" version = "1.1.6" +license = "MIT" [source] tarball = "https://bitmath.org/code/mtdev/mtdev-1.1.6.tar.gz" diff --git a/recipes/json-c.toml b/recipes/json-c.toml index e0ae6586..f5312e14 100644 --- a/recipes/json-c.toml +++ b/recipes/json-c.toml @@ -1,6 +1,7 @@ # json-c 0.18 — lib C base (de-Alpinizada, Etapa G). compiler=zig-cc (migrado de gcc, matar-gcc 2026-07-16), configure-split, estático. name = "json-c" version = "0.18" +license = "MIT" [source] tarball = "https://s3.amazonaws.com/json-c_releases/releases/json-c-0.18.tar.gz" diff --git a/recipes/libsodium.toml b/recipes/libsodium.toml index 6275aa1e..9d04f1eb 100644 --- a/recipes/libsodium.toml +++ b/recipes/libsodium.toml @@ -2,6 +2,7 @@ # De-Alpinizada: compiler=gcc, configure-split, estático. name = "libsodium" version = "1.0.22" +license = "ISC" [source] tarball = "https://download.libsodium.org/libsodium/releases/libsodium-1.0.22.tar.gz" sha256 = "adbdd8f16149e81ac6078a03aca6fc03b592b89ef7b5ed83841c086191be3349" diff --git a/recipes/libssh2.toml b/recipes/libssh2.toml index 77339f42..8b64cba0 100644 --- a/recipes/libssh2.toml +++ b/recipes/libssh2.toml @@ -2,6 +2,7 @@ # De-Alpinizada: compiler=zig-cc (migrado de gcc, matar-gcc 2026-07-16), configure-split, crypto vía openssl del corpus, estático. name = "libssh2" version = "1.11.1" +license = "BSD-3-Clause" [source] tarball = "https://www.libssh2.org/download/libssh2-1.11.1.tar.gz" sha256 = "d9ec76cbe34db98eec3539fe2c899d26b0c837cb3eb466a56b0f109cabf658f7" diff --git a/recipes/mesa-llvmpipe.toml b/recipes/mesa-llvmpipe.toml index 2de3e558..90cfe9fa 100644 --- a/recipes/mesa-llvmpipe.toml +++ b/recipes/mesa-llvmpipe.toml @@ -8,6 +8,7 @@ # desde fuente primero (el monstruo). Test primero, decidir después. name = "mesa-llvmpipe" version = "24.0.9" +license = "MIT" [source] tarball = "https://archive.mesa3d.org/mesa-24.0.9.tar.xz" diff --git a/recipes/mesa-swrast.toml b/recipes/mesa-swrast.toml index f3b4061c..e7033395 100644 --- a/recipes/mesa-swrast.toml +++ b/recipes/mesa-swrast.toml @@ -19,6 +19,7 @@ # TODO idéntico a mesa.toml; ver su encabezado para el detalle de cada uno. name = "mesa-swrast" version = "24.0.9" +license = "MIT" [source] tarball = "https://archive.mesa3d.org/mesa-24.0.9.tar.xz" diff --git a/recipes/mtdev.toml b/recipes/mtdev.toml index c5b6eb55..e3c095c2 100644 --- a/recipes/mtdev.toml +++ b/recipes/mtdev.toml @@ -1,6 +1,7 @@ # mtdev 1.1.6 — protocolo multitouch (slots). Dep de build de libinput. Autotools, sin deps. name = "mtdev" version = "1.1.6" +license = "MIT" [source] tarball = "https://bitmath.org/code/mtdev/mtdev-1.1.6.tar.gz" diff --git a/recipes/nano.toml b/recipes/nano.toml index 014de33f..69592228 100644 --- a/recipes/nano.toml +++ b/recipes/nano.toml @@ -12,6 +12,7 @@ name = "nano" version = "9.0" +license = "GPL-3.0-or-later" [source] tarball = "https://www.nano-editor.org/dist/v9/nano-9.0.tar.xz" diff --git a/recipes/scdoc.toml b/recipes/scdoc.toml index 5737bafd..b7d16b4d 100644 --- a/recipes/scdoc.toml +++ b/recipes/scdoc.toml @@ -3,6 +3,7 @@ # sha256 del tarball (el wrapper lo calcula), y adaptar build/install del shell de abuild. name = "scdoc" version = "1.11.5" +license = "MIT" [source] tarball = "https://git.sr.ht/~sircmpwn/scdoc/archive/1.11.5.tar.gz" diff --git a/recipes/seatd.toml b/recipes/seatd.toml index 33bb520f..2d3d3c68 100644 --- a/recipes/seatd.toml +++ b/recipes/seatd.toml @@ -3,6 +3,7 @@ # queremos en el rootfs arje). Sin man-pages (evita scdoc) ni ejemplos. zig 0.13.0 (patrón musl). name = "seatd" version = "0.9.3" +license = "MIT" [source] tarball = "https://git.sr.ht/~kennylevinsen/seatd/archive/0.9.3.tar.gz" diff --git a/recipes/socat.toml b/recipes/socat.toml index a10c44fd..5a1c7a10 100644 --- a/recipes/socat.toml +++ b/recipes/socat.toml @@ -1,6 +1,7 @@ # socat 1.8.1.2 — relay de sockets (C, autotools). Deps openssl+readline. De-Alpinizada: gcc, configure-split. name = "socat" version = "1.8.1.2" +license = "GPL-2.0-or-later" [source] tarball = "http://www.dest-unreach.org/socat/download/socat-1.8.1.2.tar.gz" sha256 = "daeb9eed37a99424cd14877208706e93745c91cb86fb917a355635f4df5c8499" diff --git a/recipes/sourceview-hello.toml b/recipes/sourceview-hello.toml index d89cdd92..9275d004 100644 --- a/recipes/sourceview-hello.toml +++ b/recipes/sourceview-hello.toml @@ -3,6 +3,7 @@ # esqueleto de un editor de código real para el distro. name = "sourceview-hello" version = "5.16.0" +license = "LGPL-2.1-or-later" [source] tarball = "https://download.gnome.org/sources/gtksourceview/5.16/gtksourceview-5.16.0.tar.xz" diff --git a/recipes/tllist.toml b/recipes/tllist.toml index 9af73aad..af017342 100644 --- a/recipes/tllist.toml +++ b/recipes/tllist.toml @@ -2,6 +2,7 @@ # dep de fcft/foot. Sólo instala tllist.h + tllist.pc. meson, sin binario. name = "tllist" version = "1.1.0" +license = "MIT" [source] tarball = "https://codeberg.org/dnkl/tllist/archive/1.1.0.tar.gz" diff --git a/scripts/licencias-tarball.py b/scripts/licencias-tarball.py new file mode 100755 index 00000000..9b3f440a --- /dev/null +++ b/scripts/licencias-tarball.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +"""licencias-tarball.py — deduce el SPDX de una receta desde SU PROPIO tarball pineado, offline. + +── QUÉ AÑADE SOBRE LOS HERMANOS ──────────────────────────────────────────────────────────────── +`licencias.sh --sembrar` escribe desde una tabla curada a mano, y `licencias-desambiguar.sh` +resuelve `-only` vs `-or-later` preguntándole a la búsqueda de código de GitHub. Los dos dejan +fuera lo mismo: lo que no está en la tabla y lo que no vive en GitHub. + +Pero `work/tarballs/` guarda cada tarball indexado por su **sha256**, o sea que el árbol EXACTO que +la receta pinea ya está en disco. La evidencia más fuerte posible —la declaración del propio autor +en el commit que construimos— no necesita red: necesita abrir el tar. + +── LA JERARQUÍA DE EVIDENCIA, DE MÁS FUERTE A MÁS DÉBIL ──────────────────────────────────────── + 1. `Cargo.toml` / `meson.build` de la raíz. El autor declara el SPDX él mismo, en el formato + normativo. No hay nada que interpretar. + 2. Un único fichero en `LICENSES/` (convención REUSE, que usa todo KDE): el nombre del fichero + ES el identificador SPDX. + 3. El texto de `COPYING`/`LICENSE` + la CONCESIÓN buscada en las cabeceras de los fuentes. + +── POR QUÉ EL PASO 3 SON DOS PASOS Y NO UNO ──────────────────────────────────────────────────── +El COPYING de la GPL es IDÉNTICO para `-only` y `-or-later`: es el texto de la licencia, no la +concesión. Peor: su apéndice «cómo aplicar la licencia» contiene literalmente «or (at your option) +any later version», así que buscar esa frase en el COPYING da SIEMPRE positivo y parece evidencia +siendo plantilla. La concesión vive en las cabeceras de los fuentes y en el README — por eso se +busca ahí y se EXCLUYEN explícitamente COPYING/LICENSE/LICENCE. Es la misma regla que ya fijó +`licencias-desambiguar.sh`; acá se aplica sobre el árbol pineado en vez de sobre GitHub. + +── SE GUARDA LA CITA ─────────────────────────────────────────────────────────────────────────── +Cada veredicto sale con el fichero y la frase que lo decidió. Una licencia es una afirmación legal: +quien la revise tiene que poder auditarla sin repetir el trabajo. Lo que no alcanza para un +veredicto sale como `pendiente` con lo que SÍ se encontró, para que el humano arranque desde ahí. +NADA se escribe en las recetas: la salida es un TSV que revisa una persona y siembra `licencias.sh`. + +Uso: scripts/licencias-tarball.py informe + escribe docs/licencias-evidencia.tsv + scripts/licencias-tarball.py una sola, con el detalle a la vista +""" +import os +import re +import sys +import glob +import tarfile +import tomllib + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CACHE = os.path.join(RAIZ, "work", "tarballs") +SALIDA = os.path.join(RAIZ, "docs", "licencias-evidencia.tsv") + +# Cada licencia se reconoce por una frase que no aparece en las demás. El texto se NORMALIZA +# (espacios colapsados) antes de buscar, porque el mismo texto viene con saltos de línea distintos +# según quién lo empaquete. +# +# ⚠ No se elige «la primera que pegue». Si un fichero contiene VARIAS licencias —el LICENCE de +# OpenSSH es un compendio de cuatro— cualquier elección sería una afirmación falsa, así que se +# devuelven todas y decide un humano. Las únicas colapsables son las que se contienen entre sí: +# el texto de BSD-3 incluye el de BSD-2, y el de 0BSD se parece al de ISC sin su cláusula. +FAMILIAS = [ + ("AGPL-3.0", "GNU AFFERO GENERAL PUBLIC LICENSE"), + ("LGPL-3.0", "GNU LESSER GENERAL PUBLIC LICENSE Version 3"), + ("LGPL-2.1", "GNU LESSER GENERAL PUBLIC LICENSE Version 2.1"), + ("LGPL-2.0", "GNU LIBRARY GENERAL PUBLIC LICENSE"), + ("GPL-3.0", "GNU GENERAL PUBLIC LICENSE Version 3"), + ("GPL-2.0", "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"), + ("Apache-2.0", "Apache License Version 2.0"), + ("MPL-2.0", "Mozilla Public License Version 2.0"), + ("CC0-1.0", "CC0 1.0 Universal"), + ("0BSD", "Permission to use, copy, modify, and/or distribute this software for any purpose " + "with or without fee is hereby granted. THE SOFTWARE IS PROVIDED"), + ("ISC", "Permission to use, copy, modify, and/or distribute this software for any purpose " + "with or without fee is hereby granted, provided that the above copyright notice"), + ("Zlib", "altered source versions must be plainly marked as such"), + ("MIT", "without limitation the rights to use, copy, modify, merge, publish"), + ("BSD-4-Clause", "All advertising materials mentioning features or use of this software"), + ("BSD-3-Clause", "endorse or promote products derived"), + ("BSD-2-Clause", "Redistribution and use in source and binary forms"), +] +# `mayor: [las que absorbe]` — un texto que contiene al otro no son dos licencias, es una. +ABSORBE = { + "BSD-4-Clause": ["BSD-3-Clause", "BSD-2-Clause"], + "BSD-3-Clause": ["BSD-2-Clause"], + "0BSD": ["ISC"], + "LGPL-3.0": ["GPL-3.0"], + "LGPL-2.1": ["GPL-2.0"], + "AGPL-3.0": ["GPL-3.0"], +} +# Excepciones que cambian el identificador y son invisibles al texto base de la licencia. +EXCEPCIONES = [("Apache-2.0", "LLVM Exceptions", "Apache-2.0 WITH LLVM-exception")] +# Las familias GNU son las únicas donde el TEXTO no dice la concesión: `-only` y `-or-later` +# comparten COPYING palabra por palabra. Se resuelven aparte, en las cabeceras de los fuentes. +AMBIGUAS = {"AGPL-3.0", "LGPL-3.0", "LGPL-2.1", "LGPL-2.0", "GPL-3.0", "GPL-2.0"} + +NOMBRES_LICENCIA = re.compile(r"^(COPYING|LICEN[CS]E)([.-].*)?$", re.I) +# Ficheros donde SÍ vive la concesión. Se excluyen los de arriba a propósito (ver cabecera). +EXT_FUENTE = ( + ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".py", ".rs", ".go", ".sh", + ".am", ".ac", ".build", ".txt", ".md", ".in", ".pl", ".java", ".m4", +) +FRASE_LATER = re.compile( + r"(?:any|either)\s+later\s+version|(?:GPL|GPLv?[23])\+|version\s+[23](?:\.\d)?\s+or\s+(?:any\s+)?later", + re.I, +) +FRASE_ONLY = re.compile( + r"version\s+[23](?:\.\d)?\s+only|only\s+version\s+[23]|SPDX-License-Identifier:\s*\S*GPL-[23]\.0-only", + re.I, +) +def spdx_conocidos(): + """Los identificadores cuyo TEXTO tenemos en `licenses/` (los baja licencias-textos.sh). + + Es la lista blanca correcta y no una regex, por una razón que no es de comodidad: la obligación + al distribuir binarios es acompañarlos del TEXTO de la licencia. Un identificador del que no + tenemos el texto es un identificador que no podemos cumplir — así que declararlo no adelanta + nada y sí crea una afirmación que no se sostiene. Si aparece uno nuevo, el paso siguiente es + bajar su texto, no relajar la validación. + + Esto es lo que atrapa el «GPL2+» que meson deja escribir: no es un SPDX, es taquigrafía. + """ + d = os.path.join(RAIZ, "licenses") + if not os.path.isdir(d): + return set() + return {os.path.splitext(f)[0] for f in os.listdir(d) if f.endswith(".txt")} + + +CONOCIDOS = spdx_conocidos() +PEGAMENTO = {"AND", "OR", "WITH"} + + +def spdx_valido(expr): + """Toda pieza de la expresión tiene que ser un identificador del que tengamos el texto.""" + if not CONOCIDOS: + return True # sin `licenses/` no hay con qué validar; no se inventa un veredicto + piezas = [x.strip("()") for x in expr.split() if x.upper() not in PEGAMENTO] + return bool(piezas) and all(x in CONOCIDOS for x in piezas) + +SPDX_HDR = re.compile(rb"SPDX-License-Identifier:\s*([A-Za-z0-9 .+()-]+)") + +# Plantilla de autotools/libtool: viene con el generador, NO la escribió el proyecto, y casi toda +# es GPL-2.0-or-later. `nano` (GPL-3.0) salía «-or-later» citando el «either version 2» de su +# `aclocal.m4`: una concesión que el proyecto nunca hizo. La cita tiene que ser del autor. +BOILERPLATE = re.compile( + r"^(m4/|build-aux/|autom4te|aclocal\.m4$|configure$|configure\.ac$|ltmain\.sh$|" + r"config\.(guess|sub|rpath)$|install-sh$|missing$|depcomp$|compile$|test-driver$)" +) +# «either version 3 of the License» → 3. Sirve para exigir que la concesión hable de la MISMA +# versión que el COPYING: un veredicto GPL-3.0 justificado con una frase que dice «version 2» es +# incoherente, y era exactamente el caso de nano. +VERSION_EN_FRASE = re.compile(r"version\s+([23])(?:\.\d)?|GPL-?v?([23])\+|GPL-([23])\.0", re.I) + +MAX_BYTES = 512 * 1024 # un fichero de fuente más grande que esto no es una cabecera de licencia +MAX_MIEMBROS = 6000 # cota para no recorrer árboles gigantes enteros + + +def recetas_sin_licencia(): + out = [] + for f in sorted(glob.glob(os.path.join(RAIZ, "recipes", "*.toml")) + + glob.glob(os.path.join(RAIZ, "recipes", "*", "*.toml"))): + try: + d = tomllib.load(open(f, "rb")) + except Exception: + continue + if "name" in d and not d.get("license"): + out.append((f, d)) + return out + + +def sin_prefijo(nombre): + """`kcompletion-6.27.0/src/x.c` → `src/x.c`. El tar trae un directorio raíz.""" + return nombre.split("/", 1)[1] if "/" in nombre else "" + + +def una_linea(txt, tope=160): + """La cita se recorta a UNA línea: lleva texto de un fuente y sus saltos partían la fila del TSV + en varias, que es un fichero corrupto con aspecto de fichero bueno.""" + t = " ".join(str(txt).split()).replace("\t", " ") + return t if len(t) <= tope else t[:tope - 1] + "…" + + +def concuerda(cita, mayor): + """¿La cita habla de la misma versión que el COPYING? Si no, no justifica nada.""" + if not cita: + return False + m = VERSION_EN_FRASE.search(cita) + if not m: + return True # una cabecera SPDX sin número (p. ej. «GPL-2.0-only») ya es explícita + return (m.group(1) or m.group(2) or m.group(3)) == mayor + + +def identificar(texto): + """Todas las licencias presentes en un texto, ya colapsadas las que se contienen.""" + # Se quita el prefijo de comentario de cada línea antes de colapsar: el COPYING de libssh2 va + # dentro de un bloque `/* … */` y sus `*` partían la frase «endorse or * promote products», + # que es justo la marca de la cláusula 3 ⇒ se declaraba BSD-2 un texto BSD-3. + t = " ".join(re.sub(r"^[\s*#/]+", "", l) for l in texto.split("\n")) + t = " ".join(t.split()) + hits = [spdx for spdx, marca in FAMILIAS if " ".join(marca.split()) in t] + for mayor, menores in ABSORBE.items(): + if mayor in hits: + hits = [h for h in hits if h not in menores] + for base, marca, con_excepcion in EXCEPCIONES: + if hits == [base] and marca in t: + hits = [con_excepcion] + return hits + + +def declaracion_del_autor(ruta, texto): + """El SPDX que el propio autor escribe en su fichero de build. Evidencia de primera.""" + base = os.path.basename(ruta) + if base == "Cargo.toml": + m = re.search(r'^\s*license\s*=\s*"([^"]+)"', texto, re.M) + if m: + return m.group(1), f"{ruta}: license = \"{m.group(1)}\"" + if base == "meson.build": + m = re.search(r"license\s*:\s*\[?\s*'([^']+)'", texto) + if m: + return m.group(1), f"{ruta}: license: '{m.group(1)}'" + return None, None + + +def analizar(tar_path): + """Un solo recorrido del tar. Devuelve (spdx, cita) o (None, pista).""" + autor = None + reuse = [] # ficheros de LICENSES/ (convención REUSE: el nombre ES el SPDX) + candidatos = [] # (prioridad, ruta, texto) de los ficheros de licencia de la RAÍZ + familia = None + familia_fichero = None + varias = None + cita_later = None + cita_only = None + spdx_headers = {} + + try: + tf = tarfile.open(tar_path, "r:*") + except Exception as e: + return None, f"no se pudo abrir el tar: {e}" + + with tf: + for i, m in enumerate(tf): + if i > MAX_MIEMBROS: + break + if not m.isfile() or m.size > MAX_BYTES: + continue + rel = sin_prefijo(m.name) + if not rel: + continue + base = os.path.basename(rel) + + if rel.startswith("LICENSES/"): + reuse.append(base) + continue + + # ⚠ SÓLO la raíz del árbol. `pigz` trae `zopfli/COPYING` (Apache-2.0) y el paquete NO es + # Apache: un COPYING de un subdirectorio es la licencia de una PIEZA EMPAQUETADA, no la + # del paquete. Tomar el primero que aparezca convierte una dep vendorizada en el + # veredicto del contenedor. + if "/" in rel: + es_licencia_raiz = False + else: + es_licencia_raiz = NOMBRES_LICENCIA.match(base) is not None + + interesa_autor = rel in ("Cargo.toml", "meson.build") + es_licencia = es_licencia_raiz + es_fuente = rel.lower().endswith(EXT_FUENTE) or base in ("README", "AUTHORS", "NEWS") + if not (interesa_autor or es_licencia or es_fuente): + continue + + try: + datos = tf.extractfile(m).read() + except Exception: + continue + + if interesa_autor and autor is None: + lic, cita = declaracion_del_autor(rel, datos.decode("utf-8", "replace")) + if lic: + autor = (lic, cita) + + if es_licencia: + # `COPYING` a secas gana a `COPYING.OpenSSL`: el sufijado suele ser una EXCEPCIÓN o + # una licencia añadida, no la del paquete. socat es GPL-2.0 y su `COPYING.OpenSSL` + # es BSD — quedarse con el primero que aparezca lo declaraba BSD. + exacto = re.fullmatch(r"(COPYING|LICEN[CS]E)", base, re.I) is not None + candidatos.append((0 if exacto else 1, rel, datos.decode("utf-8", "replace"))) + continue # NUNCA se busca la concesión dentro del propio COPYING + + if es_fuente and BOILERPLATE.match(rel): + es_fuente = False + + if es_fuente: + h = SPDX_HDR.search(datos) + if h: + ident = h.group(1).decode().strip() + spdx_headers[ident] = spdx_headers.get(ident, 0) + 1 + texto = datos.decode("utf-8", "replace") + if cita_later is None: + mm = FRASE_LATER.search(texto) + if mm: + cita_later = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…" + if cita_only is None: + mm = FRASE_ONLY.search(texto) + if mm: + cita_only = f"{rel}: …{texto[max(0, mm.start()-60):mm.end()+20].strip()}…" + + if candidatos: + exactos = [c for c in candidatos if c[0] == 0] + if not exactos and len(candidatos) > 1: + # ffmpeg trae COPYING.GPLv2, COPYING.GPLv3, COPYING.LGPLv2.1 y COPYING.LGPLv3 en la + # raíz: cuál rige lo deciden los FLAGS de la receta (`--enable-gpl`), no el árbol. + # Elegir el primero por orden alfabético sería inventar. + return None, ("la raíz trae " + str(len(candidatos)) + " ficheros de licencia y ninguno " + "canónico (" + ", ".join(sorted(c[1] for c in candidatos)) + + "): decide la receta, no el árbol") + _, familia_fichero, texto = sorted(candidatos)[0] + hits = identificar(texto) + if len(hits) == 1: + familia = hits[0] + elif len(hits) > 1: + varias = hits + + # 1. Declaración del autor — si es un SPDX de verdad. meson deja escribir cualquier cadena, y + # `gsd-schemas`/`libgdm` declaran «GPL2+», que NO es un identificador: sembrarlo sería meter + # basura con cara de dato. Se devuelve como pendiente, con lo que el autor puso a la vista. + if autor: + if spdx_valido(autor[0]): + return autor + return None, (f"el autor declara «{autor[0]}», que no es un SPDX del que tengamos texto " + f"en licenses/ ({autor[1]})") + + # 2. REUSE con un único fichero de licencia: el nombre es el SPDX. + únicos = sorted(set(os.path.splitext(x)[0] for x in reuse)) + if len(únicos) == 1: + return únicos[0], f"LICENSES/{reuse[0]} (único, convención REUSE)" + + # 3. Texto de la licencia + concesión en las cabeceras. + if familia: + if familia not in AMBIGUAS: + return familia, f"{familia_fichero} (texto de {familia})" + mayor = familia.rsplit("-", 1)[1][0] # «GPL-3.0» → «3» + if not concuerda(cita_later, mayor): + cita_later = None + if not concuerda(cita_only, mayor): + cita_only = None + if cita_later and not cita_only: + return f"{familia}-or-later", f"{familia_fichero} + concesión en {cita_later}" + if cita_only and not cita_later: + return f"{familia}-only", f"{familia_fichero} + concesión en {cita_only}" + pista = f"{familia} ambigua" + if cita_later and cita_only: + pista += " · el árbol dice las DOS cosas, decide un humano" + else: + pista += " · sin concesión fuera del COPYING" + return None, pista + + if varias: + return None, (f"{familia_fichero} mezcla {len(varias)} licencias (" + + ", ".join(varias) + "): la expresión la compone un humano") + if únicos: + return None, "REUSE con varias licencias: " + ",".join(únicos) + if spdx_headers: + top = sorted(spdx_headers.items(), key=lambda x: -x[1])[:3] + return None, "sólo cabeceras SPDX: " + ", ".join(f"{k}×{v}" for k, v in top) + return None, "sin COPYING ni declaración en el árbol" + + +def main(): + objetivo = sys.argv[1] if len(sys.argv) > 1 else None + filas, pendientes, sin_tar = [], [], [] + + for f, d in recetas_sin_licencia(): + nombre = d["name"] + if objetivo and nombre != objetivo and os.path.basename(f) != objetivo: + continue + sha = d.get("source", {}).get("sha256") + if not sha: + sin_tar.append((nombre, "fuente git/dir: no hay tarball que mirar")) + continue + tar_path = os.path.join(CACHE, sha + ".tar") + if not os.path.exists(tar_path): + sin_tar.append((nombre, "tarball no cacheado")) + continue + spdx, cita = analizar(tar_path) + if spdx: + filas.append((nombre, spdx, una_linea(cita))) + else: + pendientes.append((nombre, una_linea(cita))) + + if objetivo: + for n, s, c in filas: + print(f"{n}\t{s}\n evidencia: {c}") + for n, c in pendientes + sin_tar: + print(f"{n}\tPENDIENTE — {una_linea(c)}") + return + + with open(SALIDA, "w") as fh: + fh.write("# licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el\n") + fh.write("# TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo\n") + fh.write("# decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.\n") + for n, s, c in filas: + fh.write(f"{n}\t{s}\t{c}\n") + + print(f"==> resueltas con evidencia del tarball: {len(filas)}") + for n, s, c in filas: + print(f" {n:28s} {s:34s} {c[:70]}") + print(f"\n==> pendientes (hay tarball pero no alcanza para afirmar): {len(pendientes)}") + for n, c in pendientes: + print(f" {n:28s} {c}") + print(f"\n==> sin tarball que mirar: {len(sin_tar)}") + for n, c in sin_tar: + print(f" {n:28s} {c}") + print(f"\n==> escrito {os.path.relpath(SALIDA, RAIZ)} · NADA se tocó en recipes/") + + +if __name__ == "__main__": + main()