1.6.2: manifiesto firmado, tiempo estimado en tareas, pruebas automáticas

- firma.py: RSA-2048 PKCS#1 v1.5 SHA-256 sin dependencias; empaquetar.py firma
  el manifiesto y actualizador.py exige la firma (CLAVE_PUBLICA incrustada).
- Barra de tareas: tiempo restante estimado a partir del avance.
- Corregido: APLICANDO nunca llegaba al total y «Moviendo» quedaba fijo,
  bloqueando desconectar la carpeta.
- probar.py: reglas, firma y recorrido completo sobre una copia temporal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 14:48:47 -05:00
padre 8c5153e8ed
commit 8b482af652
Se han modificado 9 ficheros con 414 adiciones y 14 borrados

Ver fichero

@@ -3,7 +3,7 @@ from PyInstaller.utils.hooks import collect_all
datas = [] datas = []
binaries = [] binaries = []
hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador'] hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador', 'firma']
tmp_ret = collect_all('webview') tmp_ret = collect_all('webview')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('clr_loader') tmp_ret = collect_all('clr_loader')

Ver fichero

@@ -264,6 +264,17 @@ a soltar la carpeta el escaneo anterior sigue ahí). No deja desconectar mientra
se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar
{raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`. {raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`.
1.6.2: el manifiesto de actualización va firmado. `firma.py` (RSA-2048 sin dependencias) firma
versión+archivo+sha256+tamaño al empaquetar con la clave privada de `%USERPROFILE%\.labstream\organizador-firma.json`
(haz copia de seguridad: sin ella no se pueden publicar versiones nuevas); la pública está incrustada en
`actualizador.py` y el .exe no instala nada que no cuadre con ella, aunque alguien cambiara el archivo en el
servidor. Los .exe anteriores (≤ 1.6.1) ignoran la firma y siguen actualizándose. La barra de tareas muestra el
tiempo restante estimado (≈ 4 min) a partir del avance. Corregido: tras aplicar el plan, «Moviendo» se quedaba
en la barra de tareas para siempre y bloqueaba desconectar la carpeta. Pruebas automáticas en `probar.py`
(reglas, firma, y un recorrido completo escanear → plan → aplicar → deshacer → desconectar sobre una copia
temporal): `py -3.12 probar.py`. Rendimiento medido sobre el inventario real (54 mil archivos): las consultas
con LIKE tardan menos de 10 ms; no hacía falta optimizar.
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -13,6 +13,7 @@ Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y s
- nombre de archivo simple (sin rutas) terminado en .zip - nombre de archivo simple (sin rutas) terminado en .zip
- sha256 de 64 hex, obligatorio - sha256 de 64 hex, obligatorio
- URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor) - URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor)
- firma RSA (firma.py) de versión+archivo+sha256+tamaño que cuadre con CLAVE_PUBLICA; sin ella no se instala
`O` es el módulo organizador (lo fija él al arrancar). `O` es el módulo organizador (lo fija él al arrancar).
""" """
@@ -28,6 +29,7 @@ import tempfile
import threading import threading
import subprocess import subprocess
import urllib.error import urllib.error
import firma
import urllib.request import urllib.request
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
@@ -55,6 +57,10 @@ def _tupla(v):
return tuple(out + [0] * (3 - len(out))) return tuple(out + [0] * (3 - len(out)))
# clave pública de firma.py (la privada está en ~/.labstream/organizador-firma.json, solo la usa empaquetar.py)
CLAVE_PUBLICA = {"e": 65537, "n": 24937087096150856258549531047458881410156319770314378325709810834727102787144078753904837107336360100277476122470417827694103702029600632424402930901393600571590411504459673905624547394933513534401412673838815719634257887193733377640612491708305947101493071063533297310409777772800911393696346540036769491297235656643827279260544972034028016485933509783117840560074417501599224256143123720254264555615803987537224119534643367425486525306755766385726396897033934795259582227391903282650010470084464660818450797096588538639818043343189296266160938215570507865913766129394056803324311835125960516203968300455854004216307}
def _validar(m, url_manifiesto): def _validar(m, url_manifiesto):
"""Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados.""" """Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados."""
app = m.get("app") or {} app = m.get("app") or {}
@@ -73,6 +79,9 @@ def _validar(m, url_manifiesto):
return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}" return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}"
if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"): if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"):
return None, "el manifiesto debe servirse por https" return None, "el manifiesto debe servirse por https"
f = m.get("firma") or {}
if not isinstance(f, dict) or not firma.verificar(firma.texto_manifiesto(m), str(f.get("valor") or ""), CLAVE_PUBLICA):
return None, "el manifiesto no está firmado con la clave de Labstream (o se modificó); no se instala nada"
return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo, return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo,
"size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, "" "size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, ""

Ver fichero

@@ -38,7 +38,7 @@ for d in ("build", "dist"):
subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed", subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed",
"--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"), "--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"),
"--hidden-import", "resolve_bridge", "--hidden-import", "reglas", "--hidden-import", "resolve_bridge", "--hidden-import", "reglas",
"--hidden-import", "asistente", "--hidden-import", "actualizador", "--hidden-import", "asistente", "--hidden-import", "actualizador", "--hidden-import", "firma",
"--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet", "--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet",
"--exclude-module", "tkinter", "organizador.py"]) "--exclude-module", "tkinter", "organizador.py"])
dest = os.path.join("dist", "OrganizadorLabstream") dest = os.path.join("dist", "OrganizadorLabstream")
@@ -118,6 +118,18 @@ manifiesto = {
"app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"}, "app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"},
"notes": notas, "notes": notas,
} }
import firma
if os.environ.get("FIRMA") == "no":
print("AVISO: manifiesto sin firmar (FIRMA=no); los .exe 1.6.2+ no lo aceptarán")
else:
if not os.path.exists(firma.RUTA_PRIVADA):
sys.exit(f"falta la clave de firma en {firma.RUTA_PRIVADA}: corre py -3.12 firma.py generar (o FIRMA=no para saltarlo)")
_priv = firma.cargar_privada()
manifiesto["firma"] = {"alg": "rsa2048-pkcs1v15-sha256", "valor": firma.firmar(firma.texto_manifiesto(manifiesto), _priv)}
import actualizador
if not firma.verificar(firma.texto_manifiesto(manifiesto), manifiesto["firma"]["valor"], actualizador.CLAVE_PUBLICA):
sys.exit("la clave privada no corresponde a CLAVE_PUBLICA de actualizador.py; el manifiesto no serviría")
print("manifiesto firmado")
ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json") ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json")
if os.path.isdir(os.path.dirname(ruta_os)): if os.path.isdir(os.path.dirname(ruta_os)):
previo = {} previo = {}

134
firma.py Archivo normal
Ver fichero

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
r"""
Firma del manifiesto de actualización (RSA-2048, PKCS#1 v1.5, SHA-256) sin dependencias.
La clave privada vive fuera del proyecto, en %USERPROFILE%\.labstream\organizador-firma.json, y solo la
usa empaquetar.py al publicar. La clave pública va incrustada en actualizador.py: un .exe instalado solo
acepta manifiestos firmados con ella, así que aunque alguien pudiera cambiar el archivo en el servidor,
no podría hacer que la app instale un paquete que no salió de aquí.
py -3.12 firma.py generar crea la clave (si no existe) y muestra la pública para pegarla
py -3.12 firma.py probar firma y verifica un texto de prueba
"""
import os
import sys
import json
import hashlib
import secrets
RUTA_PRIVADA = os.path.join(os.path.expanduser("~"), ".labstream", "organizador-firma.json")
BITS = 2048
E = 65537
# DigestInfo de SHA-256 (RFC 8017, apéndice B.1)
_DI_SHA256 = bytes.fromhex("3031300d060960864801650304020105000420")
def _es_primo(n, rondas=40):
if n < 2:
return False
for p in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
if n % p == 0:
return n == p
d, r = n - 1, 0
while d % 2 == 0:
d //= 2
r += 1
for _ in range(rondas):
a = secrets.randbelow(n - 3) + 2
x = pow(a, d, n)
if x in (1, n - 1):
continue
for _ in range(r - 1):
x = pow(x, 2, n)
if x == n - 1:
break
else:
return False
return True
def _primo(bits):
while True:
c = secrets.randbits(bits) | (1 << (bits - 1)) | 1
if _es_primo(c):
return c
def generar(ruta=RUTA_PRIVADA):
"""Crea la pareja de claves y guarda la privada en `ruta`. Devuelve la pública {n, e}."""
if os.path.exists(ruta):
raise FileExistsError(f"ya hay una clave en {ruta}; no se sobrescribe")
while True:
p, q = _primo(BITS // 2), _primo(BITS // 2)
n = p * q
phi = (p - 1) * (q - 1)
if p != q and n.bit_length() == BITS and phi % E:
break
d = pow(E, -1, phi)
os.makedirs(os.path.dirname(ruta), exist_ok=True)
with open(ruta, "w", encoding="utf-8") as fh:
json.dump({"n": n, "e": E, "d": d}, fh)
try:
os.chmod(ruta, 0o600)
except OSError:
pass
return {"n": n, "e": E}
def cargar_privada(ruta=RUTA_PRIVADA):
with open(ruta, encoding="utf-8") as fh:
return json.load(fh)
def _em(mensaje, k):
"""EMSA-PKCS1-v1_5: 00 01 FF..FF 00 DigestInfo || hash"""
t = _DI_SHA256 + hashlib.sha256(mensaje).digest()
return b"\x00\x01" + b"\xff" * (k - len(t) - 3) + b"\x00" + t
def firmar(mensaje, priv):
"""Devuelve la firma en hex del mensaje (bytes)."""
k = (priv["n"].bit_length() + 7) // 8
m = int.from_bytes(_em(mensaje, k), "big")
return pow(m, priv["d"], priv["n"]).to_bytes(k, "big").hex()
def verificar(mensaje, firma_hex, pub):
"""True si la firma corresponde al mensaje y a la clave pública {n, e}."""
try:
k = (pub["n"].bit_length() + 7) // 8
s = bytes.fromhex(firma_hex)
if len(s) != k:
return False
m = pow(int.from_bytes(s, "big"), pub["e"], pub["n"]).to_bytes(k, "big")
return secrets.compare_digest(m, _em(mensaje, k))
except Exception: # noqa
return False
def texto_manifiesto(m):
"""Lo que se firma: los datos del manifiesto que deciden qué se instala. Cambiar cualquiera invalida la firma."""
app = m.get("app") or {}
return "\n".join(["organilab-manifiesto-v1", str(m.get("version", "")), str(app.get("archivo", "")),
str(app.get("sha256", "")).lower(), str(app.get("size", ""))]).encode("utf-8")
if __name__ == "__main__":
orden = sys.argv[1] if len(sys.argv) > 1 else ""
if orden == "generar":
if os.path.exists(RUTA_PRIVADA):
pub = {k: cargar_privada()[k] for k in ("n", "e")}
print("ya existía la clave en", RUTA_PRIVADA)
else:
print("generando clave RSA-2048 (unos segundos)…")
pub = generar()
print("clave privada guardada en", RUTA_PRIVADA, "— haz copia de seguridad: sin ella no se pueden publicar versiones nuevas")
print("\nCLAVE_PUBLICA para actualizador.py:")
print(f'CLAVE_PUBLICA = {{"e": {pub["e"]}, "n": {pub["n"]}}}')
elif orden == "probar":
priv = cargar_privada()
pub = {"n": priv["n"], "e": priv["e"]}
f = firmar(b"hola", priv)
print("firma ok:", verificar(b"hola", f, pub), "· alterada:", verificar(b"holA", f, pub))
else:
print(__doc__)

Ver fichero

@@ -1,15 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "1.6.1", "version": "1.6.2",
"builtAt": "2026-09-15T19:21:15.000Z", "builtAt": "2026-09-15T19:46:00.000Z",
"archivo": "OrganizadorLabstream-portatil-1.6.1.zip", "archivo": "OrganizadorLabstream-portatil-1.6.2.zip",
"size": 198619195, "size": 198625969,
"sha256": "913cd3f1a7b04b38606b8b42c6cb6bc1b495b873771212ccbfa407b4fad60d14", "sha256": "48ef016966c45d53f6d1d934a6e7bf735c0b20e46413c487ffdd2b4b04b3ef89",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-1.6.1.zip", "archivo": "OrganizadorLabstream-app-1.6.2.zip",
"size": 14799458, "size": 14806232,
"sha256": "c722527fb16d6e139d82a268fc6fadf79a14f48eb0a134ac2240086c24f18487", "sha256": "f45a61a47ffd678f76e6e184fefff6f2cceb860a6b82f8d305b823cebbf2b853",
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.1.zip" "url": "/api/herramientas/OrganizadorLabstream-app-1.6.2.zip"
}, },
"notes": "" "notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
"valor": "013a3ac0886f655e22e2a30913be8a54880e43ec5663dcda8537a220a014438998533b1d440b84e33e7393e8d544089c215d7af3acb27390471ffa728da1d4f452cffad7534c51041f40695152431086b165348219fba51de93b1ae0403a3b748fc1e0022a707797868825d934d21d676debc6cfa8b54d6e7f4b0bd5c83b6ed6a9fe1f60bcee5cc9e5aa06710e120c2bbcdbcd59356e9931aed4c3206815b8c99746940be562a196144c9e22c4e0aa17ea43852c79a9b6e23076ff18c89c76971946aed10e05ca29aaedc95f3e0d39ffa71f020125d7bf272d9a5f9ef8b5f6b6e5cbb4930e12b4c598b6343bd779c1d0ead18aad70eaa442badc303ad22bbca3"
}
} }

Ver fichero

@@ -33,7 +33,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "1.6.1" VERSION = "1.6.2"
CONGELADO = getattr(sys, "frozen", False) CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
@@ -967,6 +967,7 @@ def aplicar(raiz, filas):
hechas += 1 hechas += 1
except Exception as ex: # noqa except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}") errores.append(f"{f.get('origen_rel')}: {ex}")
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos) # carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
for f in filas: for f in filas:
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"]) base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])

216
probar.py Archivo normal
Ver fichero

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
r"""
Pruebas automáticas de OrganiLAB. Se corren con: py -3.12 probar.py
- reglas: detección de cámara, mes, año, basura y proxies
- firma: firmar/verificar y rechazo de manifiestos alterados o sin firmar
- servidor (extremo a extremo): copia el programa a una carpeta temporal, lo arranca en un puerto libre,
registra una carpeta de ensayo, la escanea, calcula el plan, lo aplica, lo deshace, y la desconecta.
No toca datos\ ni el inventario real: todo ocurre en la carpeta temporal.
"""
import os
import sys
import json
import time
import shutil
import socket
import tempfile
import unittest
import subprocess
import urllib.parse
import urllib.error
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, AQUI)
import reglas as R # noqa: E402
import firma # noqa: E402
class Reglas(unittest.TestCase):
def test_firma_camara(self):
self.assertEqual(R.firma_archivo("C0001.MP4"), "SONY")
self.assertEqual(R.firma_archivo("C0001M01.XML"), "SONY")
self.assertEqual(R.firma_archivo("20260301_labstream0001.mp4"), "SONY")
self.assertIsNone(R.firma_archivo("informe final.docx"))
def test_mes(self):
self.assertEqual(R.detectar_mes("09- Septiembre"), (9, False))
self.assertEqual(R.detectar_mes("02-marzo"), (3, True)) # gana el nombre, con conflicto
self.assertEqual(R.detectar_mes("MAYO"), (5, False))
self.assertEqual(R.detectar_mes("3"), (3, True)) # número solo: confianza media
self.assertEqual(R.detectar_mes("13"), (None, False))
self.assertEqual(R.detectar_mes("Brigard"), (None, False))
def test_anio(self):
self.assertEqual(R.detectar_anio("2026"), 2026)
self.assertIsNone(R.detectar_anio("2026 - agosto"))
def test_basura_y_proxies(self):
self.assertTrue(R.es_basura_archivo("Thumbs.db"))
self.assertTrue(R.es_basura_archivo(".DS_Store"))
self.assertFalse(R.es_basura_archivo("C0001.MP4"))
self.assertTrue(R.es_proxy_dir("Proxy"))
self.assertFalse(R.es_proxy_dir("Entregas"))
class Firma(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="organilab-firma-")
cls.pub = firma.generar(os.path.join(cls.tmp, "clave.json"))
cls.priv = firma.cargar_privada(os.path.join(cls.tmp, "clave.json"))
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_firmar_verificar(self):
f = firma.firmar(b"hola", self.priv)
self.assertTrue(firma.verificar(b"hola", f, self.pub))
self.assertFalse(firma.verificar(b"holA", f, self.pub))
self.assertFalse(firma.verificar(b"hola", f[:-2] + "00", self.pub))
self.assertFalse(firma.verificar(b"hola", "zz", self.pub))
def test_manifiesto(self):
import actualizador
m = {"version": "9.9.9", "app": {"archivo": "OrganizadorLabstream-app-9.9.9.zip", "sha256": "a" * 64, "size": 10, "url": "/api/herramientas/x.zip"}}
url = "https://os.labstreamsas.com/herramientas/organizador.json"
d, motivo = actualizador._validar(m, url)
self.assertIsNone(d)
self.assertIn("firmado", motivo)
m["firma"] = {"valor": firma.firmar(firma.texto_manifiesto(m), self.priv)}
# firmado con OTRA clave (la de prueba): tampoco vale para la clave real
self.assertIsNone(actualizador._validar(m, url)[0])
viejo = actualizador.CLAVE_PUBLICA
actualizador.CLAVE_PUBLICA = self.pub
try:
self.assertIsNotNone(actualizador._validar(m, url)[0])
m["app"]["sha256"] = "b" * 64
self.assertIsNone(actualizador._validar(m, url)[0])
finally:
actualizador.CLAVE_PUBLICA = viejo
def _puerto_libre():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
class Servidor(unittest.TestCase):
"""Extremo a extremo sobre una copia temporal del programa."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-")
cls.app = os.path.join(cls.tmp, "app")
os.makedirs(cls.app)
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py"):
shutil.copy(os.path.join(AQUI, f), cls.app)
shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static"))
# carpeta de ensayo: cámara Sony, basura y un proxy
cls.raiz = os.path.join(cls.tmp, "Cliente Prueba")
for rel, tam in (("Rodaje/C0001.MP4", 3_000_000), ("Rodaje/C0001M01.XML", 500), ("Rodaje/Thumbs.db", 100),
("Rodaje/Proxy/C0001.MOV", 200_000), ("Entregas/Video final.mp4", 800_000), ("notas.txt", 50)):
p = os.path.join(cls.raiz, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as fh:
fh.write(os.urandom(min(tam, 4096)) * max(1, tam // 4096))
cls.puerto = _puerto_libre()
env = dict(os.environ, ORGANIZADOR_PUERTO=str(cls.puerto), PYTHONIOENCODING="utf-8")
cls.log = open(os.path.join(cls.tmp, "servidor.log"), "w", encoding="utf-8")
cls.proc = subprocess.Popen([sys.executable, "organizador.py", "--sin-navegador"], cwd=cls.app, env=env, stdout=cls.log, stderr=subprocess.STDOUT)
cls.base = f"http://127.0.0.1:{cls.puerto}"
for _ in range(60):
try:
urllib.request.urlopen(cls.base + "/api/estado", timeout=1)
break
except Exception: # noqa
time.sleep(0.25)
else:
raise RuntimeError("el servidor no arrancó: " + open(cls.log.name, encoding="utf-8", errors="replace").read()[-800:])
@classmethod
def tearDownClass(cls):
cls.proc.kill()
cls.proc.wait()
cls.log.close()
shutil.rmtree(cls.tmp, ignore_errors=True)
def get(self, p):
return json.loads(urllib.request.urlopen(self.base + p, timeout=30).read())
def post(self, p, d):
rq = urllib.request.Request(self.base + p, json.dumps(d).encode(), {"Content-Type": "application/json", "Origin": self.base})
try:
r = urllib.request.urlopen(rq, timeout=60)
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read())
def esperar_escaneo(self):
for _ in range(200):
r = next((x for x in self.get("/api/estado")["raices"] if x["ruta"].lower() == self.raiz.lower()), None)
if r and r["escaneo"] and not (r["progreso"] and r["progreso"]["estado"] == "corriendo"):
return r
time.sleep(0.1)
self.fail("el escaneo no terminó")
def _arbol(self):
out = []
for d, _, fs in os.walk(self.raiz):
for f in fs:
out.append(os.path.relpath(os.path.join(d, f), self.raiz).lower())
return sorted(out)
def test_recorrido(self):
q = "?raiz=" + urllib.parse.quote(self.raiz)
# peticiones que no son JSON: fuera
rq = urllib.request.Request(self.base + "/api/raices", b"{}", {"Content-Type": "text/plain"})
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(rq)
self.assertEqual(cm.exception.code, 403)
# 1. registrar y escanear
self.assertEqual(self.post("/api/raices", {"raices": [{"ruta": self.raiz, "cliente": "Cliente Prueba", "tipo": "contenido"}]})[0], 200)
self.assertEqual(self.post("/api/escanear", {"raiz": self.raiz})[0], 200)
r = self.esperar_escaneo()
self.assertEqual(r["escaneo"]["n"], 6)
mapa = self.get("/api/mapa" + q)
self.assertTrue(mapa["escaneado"])
self.assertGreaterEqual(mapa["basura"]["n"], 1)
self.assertGreaterEqual(mapa["proxies"]["n"], 1)
# 2. plan: nada sale de la carpeta del cliente
plan = self.get("/api/plan" + q)
self.assertTrue(plan["filas"], "el plan no propuso nada")
for f in plan["filas"]:
self.assertTrue(os.path.normpath(f["destino"]).lower().startswith(os.path.normpath(self.raiz).lower()), f["destino"])
ids = [f["id"] for f in plan["filas"]]
# destino fuera de la carpeta: rechazado
c, d = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids, "destinos": {str(ids[0]): "..\\fuera"}})
self.assertEqual(c, 400)
self.assertIn("fuera", d["error"])
# 3. aplicar: el árbol cambia pero conserva los mismos archivos, y aparece _Para borrar
antes = self._arbol()
c, res = self.post("/api/aplicar", {"raiz": self.raiz, "ids": ids})
self.assertEqual(c, 200)
self.assertEqual(res["errores"], [])
self.assertGreater(res["movimientos"], 0)
despues = self._arbol()
self.assertNotEqual(antes, despues)
self.assertEqual(sorted(os.path.basename(p) for p in antes), sorted(os.path.basename(p) for p in despues))
self.assertTrue(any("_para borrar" in p for p in despues))
# 4. deshacer deja el árbol exacto
c, res = self.post("/api/deshacer", {"lote": res["lote"]})
self.assertEqual(c, 200)
self.assertEqual(antes, self._arbol())
# 5. desconectar con datos; la segunda vez ya no existe
c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True})
self.assertEqual(c, 200)
self.assertEqual(self.get("/api/estado")["raices"], [])
self.assertEqual(self.post("/api/raiz/quitar", {"raiz": self.raiz})[0], 409)
if __name__ == "__main__":
unittest.main(verbosity=2)

Ver fichero

@@ -228,6 +228,7 @@
.tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s} .tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s}
.tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear} .tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear}
.tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap} .tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap}
.tarea .pct small{display:block;font-size:11px;color:var(--mut);font-weight:500;text-align:right}
.tarea button{padding:3px 8px;font-size:11.5px} .tarea button{padding:3px 8px;font-size:11.5px}
/* avisos */ /* avisos */
#avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px} #avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px}
@@ -558,10 +559,22 @@ function tareasDe(E){
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct}); const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
return T; return T;
} }
// tiempo restante estimado: se recuerda cuándo se vio cada tarea por primera vez y a qué % iba; con eso se extrapola
const ETA = {};
function etaDe(t){
if (t.pct == null) return '';
const k = t.q + '|' + (t.raiz || ''); const ahora = Date.now();
const h = ETA[k] && ETA[k].pct <= t.pct ? ETA[k] : (ETA[k] = {t0: ahora, pct: t.pct});
const avance = t.pct - h.pct, seg = (ahora - h.t0) / 1000;
if (avance < 2 || seg < 8) return '';
const resta = seg * (100 - t.pct) / avance;
return resta < 60 ? '≈ ' + Math.max(5, Math.round(resta / 5) * 5) + ' s' : resta < 3600 ? '≈ ' + Math.round(resta / 60) + ' min' : '≈ ' + (resta / 3600).toFixed(1) + ' h';
}
function pintarTareas(){ function pintarTareas(){
const T = tareasDe(ESTADO); const T = tareasDe(ESTADO);
const vivas = new Set(T.map(t => t.q + '|' + (t.raiz || ''))); Object.keys(ETA).forEach(k => { if (!vivas.has(k)) delete ETA[k]; });
const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0); const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0);
caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %</span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join(''); caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %<small>${etaDe(t)}</small></span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join('');
$$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); })); $$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); }));
} }