DEV Community

Alexis Román
Alexis Román

Posted on

Cómo encontrar bugs de importación en Tor usando Grafos AST

Cómo encontrar bugs de importación en Tor usando Grafos AST

Hay una clase de bug que las pruebas no atrapan y los linters no ven: el módulo funciona, pero el orden en que lo importas decide si arranca. El código no está roto; el grafo de dependencias sí.

Este es el registro de uno real, encontrado en torproject/stem —la librería de Python para controlar un daemon de Tor— y reportado en torproject/stem#160.

Paso 1 — Clonar el sujeto y declarar el universo

git clone --depth 50 https://github.com/torproject/stem.git stem_art
cd stem_art && git rev-parse HEAD
# d5012a1039f05c69ebe832723ce96ecbe8f79fe1
Enter fullscreen mode Exit fullscreen mode

El universo es solo archivos .py. Cualquier cifra que se publique después tiene que declarar ese universo: un árbol sin Python no produce grafo, y un grafo vacío no es un grafo limpio.

Paso 2 — Construir el grafo de imports desde el AST

La herramienta no ejecuta el código: parsea cada .py con ast, extrae las sentencias import/from ... import, las resuelve a módulos del corpus y separa dos subgrafos:

  • eager: la sentencia corre cuando se importa el módulo (cuerpo del módulo, incluido el cuerpo de una class).
  • diferida: vive dentro de una función; no corre al importar.

Esa separación es todo el método. Un ciclo diferido no puede cerrarse al importar. Un ciclo eager sí.

python3 tools/grafo_ciclos_tt_metal_20261026.py --root /tmp/stem_art --raices-no-resueltas
Enter fullscreen mode Exit fullscreen mode

Salida, medida sobre d5012a1:

archivos python     : 219
aristas import      : 611
  eager (al importar): 594
  diferidas          : 17
SCC total           : 5
SCC eager           : 5
  ciclo(11): stem/connection.py, stem/control.py, stem/response/__init__.py,
             stem/response/authchallenge.py, stem/response/events.py,
             stem/response/getconf.py, stem/response/getinfo.py,
             stem/response/mapaddress.py, stem/response/onion_client_auth.py,
             stem/response/protocolinfo.py, stem/socket.py
  CICLO EAGER(2): stem/response/__init__.py, stem/socket.py
Enter fullscreen mode Exit fullscreen mode

Cinco componentes fuertemente conexas, y las cinco contienen al menos una arista eager. La más pequeña es la interesante: stem/response/__init__.py ↔ stem/socket.py.

Paso 3 — Convertir la sospecha en una reproducción de dos comandos

Un ciclo eager es una hipótesis, no un hallazgo. El falsificador más barato son dos procesos nuevos, con puntos de entrada distintos:

python -c 'import stem.response'   # entrada por el paquete
python -c 'import stem.socket'     # entrada por el otro extremo
Enter fullscreen mode Exit fullscreen mode

Medido con Python 3.12.13:

--- import stem.response:  RC=1
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "stem/response/__init__.py", line 37, in <module>
    import stem.socket
  File "stem/socket.py", line 105, in <module>
    class BaseSocket(object):
  File "stem/socket.py", line 253, in BaseSocket
    async def _recv(self, handler: Callable[[asyncio.StreamReader], Awaitable[stem.response.ControlMessage]]) -> stem.response.ControlMessage:
                                                                              ^^^^^^^^^^^^^
AttributeError: cannot access submodule 'response' of module 'stem' (most likely due to a circular import)

--- import stem.socket:    RC=0
Enter fullscreen mode Exit fullscreen mode

El mismo código, dos resultados opuestos según por dónde entres. Eso es la firma de un defecto de orden de carga, no de una función mal escrita.

Por qué es un defecto de orden de carga

Tres hechos, cada uno verificable en el archivo:

  1. stem/response/__init__.py:37 hace import stem.socket mientras stem.response todavía se está inicializando. En ese instante, stem.response existe en sys.modules, pero el atributo response del paquete stem —el que hace funcionar stem.response.ControlMessage— aún no está ligado.
  2. stem/socket.py:105 abre class BaseSocket(object):. El cuerpo de una clase se ejecuta al importar, y stem/socket.py no tiene from __future__ import annotations.
  3. Por eso la anotación de stem/socket.py:253 se evalúa de inmediato, en el momento exacto en que el nombre stem.response no es alcanzable como atributo.

La anotación no es código que corra; es metadata que Python 3 evalúa con ganas (eager) si no se le dice lo contrario. Y stem/socket.py:90 (import stem.response) cierra el círculo: los dos módulos se necesitan, y quién termina primero decide si la anotación resuelve.

Cuando entras por stem.socket, stem.socket se inicializa primero, su import stem.response corre hasta el final, y para cuando toca evaluar la anotación el nombre ya existe. Cuando entras por stem.response, el orden se invierte y revienta.

El arreglo

Cualquiera de los dos rompe la dependencia de orden:

from __future__ import annotations   # al inicio de stem/socket.py
Enter fullscreen mode Exit fullscreen mode

o citar la anotación afectada ('stem.response.ControlMessage'). Ambos dejan typing.get_type_hints() funcionando, porque los nombres resuelven cuando el paquete ya está cargado.

Medido, no supuesto: con la línea añadida, import stem.response sale RC 0; revertida la línea, vuelve a salir RC 1 con el mismo AttributeError. El defecto y su cierre están en el mismo par de comandos.

Por qué un grafo AST y no un linter

  • Un linter de imports mira un archivo; el bug no está en un archivo, está en el par.
  • Las pruebas de stem no lo ven porque cada módulo de prueba importa lo que necesita en el orden que le conviene: el orden accidental del test enmascara el orden real del usuario.
  • El grafo cuesta segundos y no ejecuta el sujeto. Produce un ranking: 5 SCC, todas con arista eager. Eso reduce el espacio de búsqueda de "219 archivos" a "un par".

El costo real está en la resolución de nombres, no en el parseo: sobre este checkout, 1 015 imports no resolvieron (913 de STDLIB, 102 de dependencias externas como cryptography), y 211 aristas hacia el __init__ de un paquete ancestro fueron descartadas a propósito —el paquete ya está en sys.modules cuando corre el hijo, contarlas fabrica ciclos que el runtime nunca recorre—. Un motor que no declara esas dos decisiones publica ciclos falsos con total comodidad.

Alcance y límites

  • Ninguna afirmación de seguridad. Es un defecto de tiempo de importación: se manifiesta como un AttributeError duro en vez de un import que funciona.
  • No se probó contra un daemon de Tor en ejecución. Solo el import, en un intérprete limpio.
  • El bug es del sujeto, no del método: el método solo lo hizo visible sin leer stem/socket.py de arriba abajo primero.

Prueba

Si mantienes una librería con paquetes que se importan entre sí, el experimento te cuesta dos comandos: construye el grafo, separa eager de diferido, y prueba cada ciclo pequeño con dos puntos de entrada opuestos. El que falle en uno y no en el otro es tu bug.

Top comments (0)