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

`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 nadie curó y lo que no vive en GitHub.

Pero `work/tarballs/` indexa cada tarball por su sha256, así que el árbol EXACTO que la receta pinea
ya está en disco: la declaración del propio autor, en el commit que construimos, sin red. De las 76
sin licencia, 55 tenían su tarball cacheado y 25 salieron con evidencia citable.

`scripts/licencias-tarball.py` la busca en tres niveles: la declaración del autor (`Cargo.toml`,
`meson.build`), un único fichero en `LICENSES/` (REUSE, que usa KDE), y el texto del COPYING más la
CONCESIÓN buscada en las cabeceras de los fuentes — excluyendo COPYING/LICENSE, porque el apéndice
de la GPL trae literalmente «or (at your option) any later version» y buscarla ahí da siempre
positivo siendo plantilla. Es la regla que ya fijó `licencias-desambiguar.sh`, aplicada al árbol
pineado en vez de a GitHub.

La primera versión resolvía 40, y CUATRO estaban mal. Las dejo escritas porque son la forma del
error, no accidentes:

  · `socat` salía BSD-2 por su `COPYING.OpenSSL`, que es la excepción, no la licencia. Un `COPYING`
    a secas gana ahora a cualquier sufijado.
  · `pigz` salía Apache-2.0 por `zopfli/COPYING`: la licencia de una pieza VENDORIZADA leída como la
    del contenedor. Sólo se mira la raíz.
  · `openssh` salía MIT porque su `LICENCE` es un compendio de cuatro y me quedaba con la primera
    que pegara. Si el texto trae varias, no hay veredicto: lo compone un humano.
  · `nano` salía GPL-3.0-**or-later** citando el «either version 2» de su `aclocal.m4` — plantilla de
    autotools, no del proyecto, y encima de otra versión. Ahora la cita tiene que venir de un fichero
    del autor Y hablar de la misma versión mayor que el COPYING.

Y dos bugs míos que producían el mismo daño en silencio: la marca de BSD-3 era «Neither the name of»,
que libpcap y libzip no usan («The names of the authors may not be used to endorse») ⇒ se declaraban
BSD-2; y el mayor de versión lo sacaba de `"GPL-3.0".rsplit("-",1)[0][-1]`, que da «L», así que la
comprobación de coherencia rechazaba TODA cita válida.

La validación final no es una regex: un identificador vale si tenemos su texto en `licenses/`. La
obligación legal es acompañar el binario del TEXTO, así que un SPDX que no podemos entregar no
adelanta nada y sí crea una afirmación que no se sostiene. Eso es lo que atrapa el «GPL2+» que meson
deja escribir en `gsd-schemas` y `libgdm`, que no es un SPDX sino taquigrafía.

Nada se sembró a ojo: cada línea de `docs/licencias-evidencia.tsv` lleva la cita que la decidió, y
las 50 que quedan salen listadas con lo que SÍ se encontró en vez de rellenarse.

Comprobado que no se invalida nada: los 26 ArtifactHash afectados son idénticos antes y después
(`license` no está en `hash_inputs`) — medido, no supuesto.
This commit is contained in:
Sergio
2026-09-05 15:34:35 +00:00
parent 33f7c37312
commit ba1fb2d3a8
28 changed files with 465 additions and 0 deletions
+29
View File
@@ -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'
1 # licencias-evidencia.tsv — lo generó scripts/licencias-tarball.py desde el
2 # TARBALL PINEADO de cada receta. Tres columnas: paquete, SPDX, y la CITA que lo
3 # decidió. REVISAR A MANO antes de sembrar: es evidencia, no un veredicto firmado.
4 adwaita-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
5 dwarves GPL-2.0-only COPYING + concesión en btf_encoder.c: …/* SPDX-License-Identifier: GPL-2.0-only Copyright (C) 20…
6 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…
7 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…
8 file BSD-2-Clause COPYING (texto de BSD-2-Clause)
9 go BSD-3-Clause LICENSE (texto de BSD-3-Clause)
10 gtk4-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
11 hammer-edit LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
12 dav1d BSD-2-Clause COPYING (texto de BSD-2-Clause)
13 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…
14 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…
15 libpcap BSD-3-Clause LICENSE (texto de BSD-3-Clause)
16 libzip BSD-3-Clause LICENSE (texto de BSD-3-Clause)
17 mtdev MIT COPYING (texto de MIT)
18 json-c MIT COPYING (texto de MIT)
19 libsodium ISC LICENSE (texto de ISC)
20 libssh2 BSD-3-Clause COPYING (texto de BSD-3-Clause)
21 mesa-llvmpipe MIT meson.build: license: 'MIT'
22 mesa-swrast MIT meson.build: license: 'MIT'
23 mtdev MIT COPYING (texto de MIT)
24 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…
25 scdoc MIT COPYING (texto de MIT)
26 seatd MIT meson.build: license: 'MIT'
27 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…
28 sourceview-hello LGPL-2.1-or-later meson.build: license: 'LGPL-2.1-or-later'
29 tllist MIT meson.build: license: 'MIT'
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+410
View File
@@ -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 <receta> 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()