diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4504a39 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +# Tests en las tres plataformas donde se publica un binario. +# +# Lo llama tambien deploy.yml antes de tocar produccion: hasta ahora un push a +# main se desplegaba sin ejecutar nada, asi que un commit que rompiese el +# servidor de senializacion llegaba al VPS y solo se notaba al usarlo. +# +# La matriz no es ceremonia: la suite arranca procesos, abre puertos y escribe +# ficheros temporales, y eso es justo lo que se comporta distinto en Windows y en +# macOS. El CLI se publica para las tres. + +name: CI + +on: + pull_request: + workflow_call: + # En un push a main, deploy.yml ya llama a este workflow antes de desplegar. + # Aqui quedan las rutas que no despliegan nada y que se quedarian sin probar: + # el CLI, la propia suite y los scripts de compilacion y firma. + push: + branches: + - main + paths: + - 'cli/**' + - 'test/**' + - 'scripts/**' + - 'sea-config.json' + - '.github/workflows/ci.yml' + +jobs: + test: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + # La suite levanta su propio servidor en un puerto efimero y lo apaga al + # acabar, asi que no hay nada que preparar antes. + - run: npm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dc332ec..66feac2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,16 +14,31 @@ on: - '.github/workflows/deploy.yml' jobs: + # Los mismos tests que en CI, y el despliegue depende de ellos: el VPS hace + # `git reset --hard origin/main`, asi que sin esta puerta un commit roto llega + # a produccion y solo se descubre usandolo. + tests: + name: Tests + uses: ./.github/workflows/ci.yml + deploy: name: Deploy to Production + needs: tests runs-on: ubuntu-latest steps: - name: Deploy via SSH uses: appleboy/ssh-action@v1.2.0 + env: + # El dominio publico sale de una variable del repositorio. Escrito en el + # script, cualquiera que bifurque el repo comprobaria la salud del + # servidor de otro. Si no esta puesta, solo se comprueba por dentro del + # contenedor. + HEALTH_URL: ${{ vars.DROP_PUBLIC_URL }} with: host: ${{ secrets.DROP_HOST }} username: ${{ secrets.DROP_USER }} key: ${{ secrets.DROP_SSH_KEY }} + envs: HEALTH_URL script: | set -e echo "==> Actualizando código en ~/drop..." @@ -40,5 +55,12 @@ jobs: echo "==> Comprobando estado del servicio..." sleep 3 - curl -sf https://drop.oloxx.dev/healthz || docker compose exec -T drop node -e "fetch('http://localhost:3000/healthz').then(r => process.exit(r.ok ? 0 : 1))" + interno() { + docker compose exec -T drop node -e "fetch('http://localhost:3000/healthz').then(r => process.exit(r.ok ? 0 : 1))" + } + if [ -n "$HEALTH_URL" ]; then + curl -sf "$HEALTH_URL/healthz" || interno + else + interno + fi echo "==> ¡Despliegue completado con éxito!" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d61cbb..acdc051 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,34 +30,23 @@ jobs: node-version: 22 cache: npm - # La version vive en dos sitios a mano (package.json y la constante - # VERSION de cli/src/cli.js). Si divergen, `drop update` compara contra la - # constante y los clientes no detectan la actualizacion, o la detectan en - # bucle. Mientras eso no se arregle (issue #26), esto lo caza aqui. - - name: Comprobar que el tag, package.json y cli.js coinciden + # La version del CLI sale del package.json (issue #26), asi que aqui solo + # queda contrastar el tag: etiquetar v0.5.3 con el package.json en 0.5.2 + # publicaria binarios que se presentan con otra version que la release. + - name: Comprobar que el tag y el package.json coinciden run: | TAG="${GITHUB_REF_NAME#v}" PKG=$(node -p "require('./package.json').version") - CLI=$(node -p "require('fs').readFileSync('cli/src/cli.js','utf8').match(/const VERSION = '([^']+)'/)[1]") - echo "tag=$TAG package.json=$PKG cli.js=$CLI" - if [ "$TAG" != "$PKG" ] || [ "$TAG" != "$CLI" ]; then - echo "::error::La version no coincide en los tres sitios. Corrige y vuelve a etiquetar." + echo "tag=$TAG package.json=$PKG" + if [ "$TAG" != "$PKG" ]; then + echo "::error::El tag $GITHUB_REF_NAME no cuadra con la version $PKG del package.json." exit 1 fi - run: npm ci - # `test/signaling.test.mjs` se conecta a un servidor que espera ya - # levantado. Hasta que la suite arranque el suyo (issue #23), se levanta - # aqui y se espera a que /healthz responda. - - name: Levantar el servidor de senializacion - run: | - node server/index.js & - for i in $(seq 1 30); do - curl -sf http://localhost:3000/healthz && break - sleep 1 - done - + # La suite levanta su propio servidor de senializacion (issue #23): aqui no + # hay que preparar nada. - run: npm test # ------------------------------------------------------------------- macOS diff --git a/README.md b/README.md index 71e0a34..44845fb 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ npm run dev # Ejecutar el CLI en modo desarrollo npm run cli -- send mi_archivo.zip -# Ejecutar la suite de tests +# Ejecutar la suite de tests (levanta su propio servidor, no hace falta nada mas) npm test # Benchmarks de velocidad diff --git a/cli/src/cli.js b/cli/src/cli.js index 2c505b5..d74b56b 100644 --- a/cli/src/cli.js +++ b/cli/src/cli.js @@ -17,8 +17,14 @@ import { runSpeedHost, runSpeedGuest } from './speed.js'; import { mapPort } from './upnp.js'; import { newCode, parseCode, randomRoomId, CodeError } from '../../public/shared/codes.js'; import { verifySignature } from './minisign.js'; - -const VERSION = '0.5.2'; +import pkg from '../../package.json' with { type: 'json' }; + +// La version sale del package.json y de ningun otro sitio. Estuvo escrita a mano +// tambien aqui, y desincronizarlas no es cosmetico: `drop update` compara la +// release de GitHub contra esta constante, asi que una constante vieja deja al +// CLI creyendose desactualizado para siempre, o al reves. Al empaquetar, esbuild +// mete el JSON dentro del bundle, asi que el binario tampoco lee nada en marcha. +const VERSION = pkg.version; // Clave publica con la que se firma cada release (formato minisign). La privada // vive como secret del repositorio y solo la toca el workflow de publicacion. diff --git a/public/app.js b/public/app.js index e8aa5a1..a4976a5 100644 --- a/public/app.js +++ b/public/app.js @@ -38,6 +38,7 @@ import { parseCode, randomSecretWords, formatCode, CodeError } from './shared/codes.js'; import { sasInput, sasWords, formatSas, dtlsFingerprints } from './shared/sas.js'; +import { Sha256, sha256Hex } from './shared/sha256.js'; const $ = (sel, root = document) => root.querySelector(sel); @@ -61,144 +62,6 @@ const PATH_EVERY = 3000; // cada cuanto refrescamos camino y latencia en la fi // ---------------------------------------------------------------- utilidades -const SHA256_K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 -]); - -class Sha256 { - constructor() { - this.h0 = 0x6a09e667; - this.h1 = 0xbb67ae85; - this.h2 = 0x3c6ef372; - this.h3 = 0xa54ff53a; - this.h4 = 0x510e527f; - this.h5 = 0x9b05688c; - this.h6 = 0x1f83d9ab; - this.h7 = 0x5be0cd19; - this.block = new Uint8Array(64); - this.blockLen = 0; - this.totalLen = 0; - this.w = new Uint32Array(64); - } - - _processBlock(b) { - const w = this.w; - for (let i = 0; i < 16; i++) { - const p = i * 4; - w[i] = (b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]; - } - for (let i = 16; i < 64; i++) { - const v0 = w[i - 15]; - const s0 = ((v0 >>> 7) | (v0 << 25)) ^ ((v0 >>> 18) | (v0 << 14)) ^ (v0 >>> 3); - const v1 = w[i - 2]; - const s1 = ((v1 >>> 17) | (v1 << 15)) ^ ((v1 >>> 19) | (v1 << 13)) ^ (v1 >>> 10); - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; - } - - let a = this.h0, b0 = this.h1, c = this.h2, d = this.h3; - let e = this.h4, f = this.h5, g = this.h6, h = this.h7; - - for (let i = 0; i < 64; i++) { - const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); - const ch = (e & f) ^ ((~e) & g); - const temp1 = (h + S1 + ch + SHA256_K[i] + w[i]) | 0; - const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); - const maj = (a & b0) ^ (a & c) ^ (b0 & c); - const temp2 = (S0 + maj) | 0; - - h = g; - g = f; - f = e; - e = (d + temp1) | 0; - d = c; - c = b0; - b0 = a; - a = (temp1 + temp2) | 0; - } - - this.h0 = (this.h0 + a) | 0; - this.h1 = (this.h1 + b0) | 0; - this.h2 = (this.h2 + c) | 0; - this.h3 = (this.h3 + d) | 0; - this.h4 = (this.h4 + e) | 0; - this.h5 = (this.h5 + f) | 0; - this.h6 = (this.h6 + g) | 0; - this.h7 = (this.h7 + h) | 0; - } - - update(data) { - const bytes = data instanceof Uint8Array - ? data - : new Uint8Array(data.buffer || data, data.byteOffset || 0, data.byteLength || data.length); - let offset = 0; - const len = bytes.length; - this.totalLen += len; - - if (this.blockLen > 0) { - const needed = 64 - this.blockLen; - if (len >= needed) { - this.block.set(bytes.subarray(0, needed), this.blockLen); - this._processBlock(this.block); - this.blockLen = 0; - offset = needed; - } else { - this.block.set(bytes, this.blockLen); - this.blockLen += len; - return this; - } - } - - while (offset + 64 <= len) { - this._processBlock(bytes.subarray(offset, offset + 64)); - offset += 64; - } - - if (offset < len) { - this.block.set(bytes.subarray(offset), 0); - this.blockLen = len - offset; - } - - return this; - } - - digest() { - const totalBits = this.totalLen * 8; - this.block[this.blockLen++] = 0x80; - if (this.blockLen > 56) { - this.block.fill(0, this.blockLen); - this._processBlock(this.block); - this.blockLen = 0; - } - this.block.fill(0, this.blockLen, 56); - const hiBits = Math.floor(totalBits / 0x100000000); - const loBits = totalBits >>> 0; - this.block[56] = (hiBits >>> 24) & 0xff; - this.block[57] = (hiBits >>> 16) & 0xff; - this.block[58] = (hiBits >>> 8) & 0xff; - this.block[59] = hiBits & 0xff; - this.block[60] = (loBits >>> 24) & 0xff; - this.block[61] = (loBits >>> 16) & 0xff; - this.block[62] = (loBits >>> 8) & 0xff; - this.block[63] = loBits & 0xff; - this._processBlock(this.block); - - const hash = [this.h0, this.h1, this.h2, this.h3, this.h4, this.h5, this.h6, this.h7]; - return hash.map((v) => (v >>> 0).toString(16).padStart(8, '0')).join(''); - } -} - -/** sha256 hexadecimal de una cadena, con el mismo Sha256 que verifica los archivos. */ -function sha256Hex(text) { - return new Sha256().update(new TextEncoder().encode(text)).digest(); -} - /** Misma formula que `secretProof` de cli/src/crypto.js: los dos extremos deben coincidir. */ function secretProof(nonce, secret) { return sha256Hex(`drop-proof-v2|${nonce}|${secret}`); diff --git a/public/shared/sha256.js b/public/shared/sha256.js new file mode 100644 index 0000000..817f6ef --- /dev/null +++ b/public/shared/sha256.js @@ -0,0 +1,148 @@ +// SHA-256 en JavaScript, incremental. +// +// Hay implementacion propia porque hace falta ir alimentando el hash trozo a +// trozo segun llegan los datos, y `crypto.subtle.digest` es de un solo golpe: +// obligaria a tener el archivo entero en memoria, que es justo lo que la +// escritura en streaming evita. +// +// Vive aqui, y no dentro de app.js, para que el test que la comprueba use ESTE +// codigo. Antes el test copiaba las doscientas lineas y validaba su copia: un +// fallo corregido en app.js lo habria dejado en verde contra el codigo viejo. + +const SHA256_K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +]); + +export class Sha256 { + constructor() { + this.h0 = 0x6a09e667; + this.h1 = 0xbb67ae85; + this.h2 = 0x3c6ef372; + this.h3 = 0xa54ff53a; + this.h4 = 0x510e527f; + this.h5 = 0x9b05688c; + this.h6 = 0x1f83d9ab; + this.h7 = 0x5be0cd19; + this.block = new Uint8Array(64); + this.blockLen = 0; + this.totalLen = 0; + this.w = new Uint32Array(64); + } + + _processBlock(b) { + const w = this.w; + for (let i = 0; i < 16; i++) { + const p = i * 4; + w[i] = (b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]; + } + for (let i = 16; i < 64; i++) { + const v0 = w[i - 15]; + const s0 = ((v0 >>> 7) | (v0 << 25)) ^ ((v0 >>> 18) | (v0 << 14)) ^ (v0 >>> 3); + const v1 = w[i - 2]; + const s1 = ((v1 >>> 17) | (v1 << 15)) ^ ((v1 >>> 19) | (v1 << 13)) ^ (v1 >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + + let a = this.h0, b0 = this.h1, c = this.h2, d = this.h3; + let e = this.h4, f = this.h5, g = this.h6, h = this.h7; + + for (let i = 0; i < 64; i++) { + const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); + const ch = (e & f) ^ ((~e) & g); + const temp1 = (h + S1 + ch + SHA256_K[i] + w[i]) | 0; + const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); + const maj = (a & b0) ^ (a & c) ^ (b0 & c); + const temp2 = (S0 + maj) | 0; + + h = g; + g = f; + f = e; + e = (d + temp1) | 0; + d = c; + c = b0; + b0 = a; + a = (temp1 + temp2) | 0; + } + + this.h0 = (this.h0 + a) | 0; + this.h1 = (this.h1 + b0) | 0; + this.h2 = (this.h2 + c) | 0; + this.h3 = (this.h3 + d) | 0; + this.h4 = (this.h4 + e) | 0; + this.h5 = (this.h5 + f) | 0; + this.h6 = (this.h6 + g) | 0; + this.h7 = (this.h7 + h) | 0; + } + + update(data) { + const bytes = data instanceof Uint8Array + ? data + : new Uint8Array(data.buffer || data, data.byteOffset || 0, data.byteLength || data.length); + let offset = 0; + const len = bytes.length; + this.totalLen += len; + + if (this.blockLen > 0) { + const needed = 64 - this.blockLen; + if (len >= needed) { + this.block.set(bytes.subarray(0, needed), this.blockLen); + this._processBlock(this.block); + this.blockLen = 0; + offset = needed; + } else { + this.block.set(bytes, this.blockLen); + this.blockLen += len; + return this; + } + } + + while (offset + 64 <= len) { + this._processBlock(bytes.subarray(offset, offset + 64)); + offset += 64; + } + + if (offset < len) { + this.block.set(bytes.subarray(offset), 0); + this.blockLen = len - offset; + } + + return this; + } + + digest() { + const totalBits = this.totalLen * 8; + this.block[this.blockLen++] = 0x80; + if (this.blockLen > 56) { + this.block.fill(0, this.blockLen); + this._processBlock(this.block); + this.blockLen = 0; + } + this.block.fill(0, this.blockLen, 56); + const hiBits = Math.floor(totalBits / 0x100000000); + const loBits = totalBits >>> 0; + this.block[56] = (hiBits >>> 24) & 0xff; + this.block[57] = (hiBits >>> 16) & 0xff; + this.block[58] = (hiBits >>> 8) & 0xff; + this.block[59] = hiBits & 0xff; + this.block[60] = (loBits >>> 24) & 0xff; + this.block[61] = (loBits >>> 16) & 0xff; + this.block[62] = (loBits >>> 8) & 0xff; + this.block[63] = loBits & 0xff; + this._processBlock(this.block); + + const hash = [this.h0, this.h1, this.h2, this.h3, this.h4, this.h5, this.h6, this.h7]; + return hash.map((v) => (v >>> 0).toString(16).padStart(8, '0')).join(''); + } +} + +/** sha256 hexadecimal de una cadena, con el mismo Sha256 que verifica los archivos. */ +export function sha256Hex(text) { + return new Sha256().update(new TextEncoder().encode(text)).digest(); +} diff --git a/test/helpers.mjs b/test/helpers.mjs new file mode 100644 index 0000000..56c0f75 --- /dev/null +++ b/test/helpers.mjs @@ -0,0 +1,118 @@ +// Utilidades compartidas por la suite: levantar un servidor de senalizacion +// propio y hablar con el por WebSocket. +// +// Arrancar el servidor desde los tests -- en vez de dar por hecho que hay uno +// escuchando en el 3000 -- es lo que permite que `npm test` funcione en un clon +// limpio y en un runner de CI, y que cada fichero de tests tenga sus propias +// cuotas por IP sin pisar las de los demas. +// +// El puerto lo elige el sistema (`freePort`), asi que dos ficheros de tests +// pueden correr a la vez sin chocar, y el origen que el servidor acepta por +// defecto (`http://localhost:`) sale de ese mismo puerto. +import net from 'node:net'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { WebSocket } from 'ws'; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SERVER = path.join(ROOT, 'server', 'index.js'); + +/** Un puerto libre, pidiendoselo al sistema y soltandolo acto seguido. */ +export function freePort() { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const { port } = srv.address(); + srv.close(() => resolve(port)); + }); + }); +} + +/** + * Arranca `server/index.js` en un puerto efimero y espera a que escuche. + * + * Devuelve las dos URL que hacen falta (`http` y `ws`), el proceso y un `stop()` + * idempotente. `env` se mezcla con el entorno del proceso, para los tests que + * necesitan limites ridiculos o un TURN de mentira. + */ +export async function startServer(env = {}) { + const port = await freePort(); + const proc = spawn(process.execPath, [SERVER], { + env: { ...process.env, PORT: String(port), ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // La salida se guarda entera: si el arranque falla, el mensaje del servidor + // explica por que mucho mejor que un tiempo de espera agotado. + let output = ''; + proc.stdout.on('data', (d) => { output += d.toString(); }); + proc.stderr.on('data', (d) => { output += d.toString(); }); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`el servidor no arranco en 10s:\n${output}`)), + 10_000, + ); + const check = () => { + if (output.includes('Drop escuchando')) { clearTimeout(timer); resolve(); } + }; + proc.stdout.on('data', check); + proc.on('error', (err) => { clearTimeout(timer); reject(err); }); + proc.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`el servidor murio con codigo ${code}:\n${output}`)); + }); + check(); + }); + + return { + proc, + port, + http: `http://127.0.0.1:${port}`, + ws: `ws://127.0.0.1:${port}`, + // El origen que el servidor acepta por defecto para este puerto. Los tests + // que mandan cabecera Origin necesitan uno que cuadre. + origin: `http://localhost:${port}`, + output: () => output, + stop: () => { if (!proc.killed) proc.kill(); }, + }; +} + +/** + * Un WebSocket con cola de mensajes: `next()` devuelve el siguiente frame JSON, + * `say()` manda uno, `until(pred)` se salta el ruido (los avisos de `guest-gone` + * se cuelan entre medias cuando el servidor echa a un receptor). + */ +export function open(url, options) { + const ws = new WebSocket(url, options); + ws.queue = []; + ws.waiters = []; + ws.on('message', (raw, isBinary) => { + if (isBinary) return; // los trozos del relay no son mensajes de control + const msg = JSON.parse(raw); + const waiter = ws.waiters.shift(); + if (waiter) waiter(msg); + else ws.queue.push(msg); + }); + ws.next = () => new Promise((resolve) => { + if (ws.queue.length) resolve(ws.queue.shift()); + else ws.waiters.push(resolve); + }); + ws.say = (obj) => ws.send(JSON.stringify(obj)); + ws.until = async (pred) => { + for (let i = 0; i < 20; i++) { + const msg = await ws.next(); + if (pred(msg)) return msg; + } + throw new Error('no llego el mensaje esperado'); + }; + return new Promise((resolve, reject) => { + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); diff --git a/test/integrity.test.mjs b/test/integrity.test.mjs index a7eb013..a5a167e 100644 --- a/test/integrity.test.mjs +++ b/test/integrity.test.mjs @@ -5,139 +5,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { createSenderServer, receiveFiles, PROTOCOL_VERSION } from '../cli/src/transfer.js'; -// Replicate the client-side Sha256 class to unit-test it directly in node:test -const SHA256_K = new Uint32Array([ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 -]); - -class ClientSha256 { - constructor() { - this.h0 = 0x6a09e667; - this.h1 = 0xbb67ae85; - this.h2 = 0x3c6ef372; - this.h3 = 0xa54ff53a; - this.h4 = 0x510e527f; - this.h5 = 0x9b05688c; - this.h6 = 0x1f83d9ab; - this.h7 = 0x5be0cd19; - this.block = new Uint8Array(64); - this.blockLen = 0; - this.totalLen = 0; - this.w = new Uint32Array(64); - } - - _processBlock(b) { - const w = this.w; - for (let i = 0; i < 16; i++) { - const p = i * 4; - w[i] = (b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]; - } - for (let i = 16; i < 64; i++) { - const v0 = w[i - 15]; - const s0 = ((v0 >>> 7) | (v0 << 25)) ^ ((v0 >>> 18) | (v0 << 14)) ^ (v0 >>> 3); - const v1 = w[i - 2]; - const s1 = ((v1 >>> 17) | (v1 << 15)) ^ ((v1 >>> 19) | (v1 << 13)) ^ (v1 >>> 10); - w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; - } - - let a = this.h0, b0 = this.h1, c = this.h2, d = this.h3; - let e = this.h4, f = this.h5, g = this.h6, h = this.h7; - - for (let i = 0; i < 64; i++) { - const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7)); - const ch = (e & f) ^ ((~e) & g); - const temp1 = (h + S1 + ch + SHA256_K[i] + w[i]) | 0; - const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10)); - const maj = (a & b0) ^ (a & c) ^ (b0 & c); - const temp2 = (S0 + maj) | 0; - - h = g; - g = f; - f = e; - e = (d + temp1) | 0; - d = c; - c = b0; - b0 = a; - a = (temp1 + temp2) | 0; - } - - this.h0 = (this.h0 + a) | 0; - this.h1 = (this.h1 + b0) | 0; - this.h2 = (this.h2 + c) | 0; - this.h3 = (this.h3 + d) | 0; - this.h4 = (this.h4 + e) | 0; - this.h5 = (this.h5 + f) | 0; - this.h6 = (this.h6 + g) | 0; - this.h7 = (this.h7 + h) | 0; - } - - update(data) { - const bytes = data instanceof Uint8Array - ? data - : new Uint8Array(data.buffer || data, data.byteOffset || 0, data.byteLength || data.length); - let offset = 0; - const len = bytes.length; - this.totalLen += len; - - if (this.blockLen > 0) { - const needed = 64 - this.blockLen; - if (len >= needed) { - this.block.set(bytes.subarray(0, needed), this.blockLen); - this._processBlock(this.block); - this.blockLen = 0; - offset = needed; - } else { - this.block.set(bytes, this.blockLen); - this.blockLen += len; - return this; - } - } - - while (offset + 64 <= len) { - this._processBlock(bytes.subarray(offset, offset + 64)); - offset += 64; - } - - if (offset < len) { - this.block.set(bytes.subarray(offset), 0); - this.blockLen = len - offset; - } - - return this; - } - - digest() { - const totalBits = this.totalLen * 8; - this.block[this.blockLen++] = 0x80; - if (this.blockLen > 56) { - this.block.fill(0, this.blockLen); - this._processBlock(this.block); - this.blockLen = 0; - } - this.block.fill(0, this.blockLen, 56); - const hiBits = Math.floor(totalBits / 0x100000000); - const loBits = totalBits >>> 0; - this.block[56] = (hiBits >>> 24) & 0xff; - this.block[57] = (hiBits >>> 16) & 0xff; - this.block[58] = (hiBits >>> 8) & 0xff; - this.block[59] = hiBits & 0xff; - this.block[60] = (loBits >>> 24) & 0xff; - this.block[61] = (loBits >>> 16) & 0xff; - this.block[62] = (loBits >>> 8) & 0xff; - this.block[63] = loBits & 0xff; - this._processBlock(this.block); - - const hash = [this.h0, this.h1, this.h2, this.h3, this.h4, this.h5, this.h6, this.h7]; - return hash.map((v) => (v >>> 0).toString(16).padStart(8, '0')).join(''); - } -} +// El MISMO modulo que usa el navegador, no una copia: el test tiene que caer si +// alguien rompe la implementacion de verdad. +import { Sha256 } from '../public/shared/sha256.js'; test('Sha256 streaming implementation matches node:crypto on various patterns', () => { const testBuffers = [ @@ -154,13 +24,13 @@ test('Sha256 streaming implementation matches node:crypto on various patterns', const expected = crypto.createHash('sha256').update(buf).digest('hex'); // Single update - const single = new ClientSha256().update(buf).digest(); + const single = new Sha256().update(buf).digest(); assert.equal(single, expected); // Fragmented updates across different chunk boundaries const chunkSizes = [1, 7, 16, 64, 128, 1024]; for (const sz of chunkSizes) { - const hasher = new ClientSha256(); + const hasher = new Sha256(); for (let i = 0; i < buf.length; i += sz) { hasher.update(buf.subarray(i, Math.min(i + sz, buf.length))); } diff --git a/test/limits.test.mjs b/test/limits.test.mjs index 8b6a273..87549f0 100644 --- a/test/limits.test.mjs +++ b/test/limits.test.mjs @@ -4,81 +4,18 @@ // (tres salas por IP, dos receptores, salas que caducan en segundo y medio) para // no tener que esperar treinta minutos a que salte nada. // -// Servidor propio a propósito: test/signaling.test.mjs comparte los contadores -// por IP entre sus casos y el último deja el cupo de 127.0.0.1 agotado. Meter -// aquí las cuotas los volvería interdependientes. +// Un servidor por caso a propósito: los contadores por IP son del proceso, y el +// test de fuerza bruta de signaling.test.mjs deja el cupo de 127.0.0.1 agotado. +// Compartiendo servidor, estos casos serían interdependientes. import test from 'node:test'; import assert from 'node:assert/strict'; -import net from 'node:net'; -import path from 'node:path'; import crypto from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { WebSocket } from 'ws'; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SERVER = path.join(ROOT, 'server', 'index.js'); +import { startServer, open, sleep } from './helpers.mjs'; const TURN_SECRET = 'secreto-de-prueba'; const TURN_URL = 'turn:turn.example:3478'; -function freePort() { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, '127.0.0.1', () => { - const { port } = srv.address(); - srv.close(() => resolve(port)); - }); - }); -} - -/** Servidor con los límites que pida cada test, ya escuchando. */ -async function startServer(env = {}) { - const port = await freePort(); - const proc = spawn(process.execPath, [SERVER], { - env: { ...process.env, PORT: String(port), ...env }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('el servidor no arrancó')), 10_000); - proc.stdout.on('data', (d) => { - if (d.toString().includes('Drop escuchando')) { clearTimeout(timer); resolve(); } - }); - proc.on('error', reject); - }); - return { - proc, - http: `http://127.0.0.1:${port}`, - ws: `ws://127.0.0.1:${port}`, - stop: () => proc.kill(), - }; -} - -/** Mismo ayudante que en signaling.test.mjs: cola de mensajes y `next()`. */ -function open(url) { - const ws = new WebSocket(url); - ws.queue = []; - ws.waiters = []; - ws.on('message', (raw, isBinary) => { - if (isBinary) return; // los chunks del relay no son mensajes de control - const msg = JSON.parse(raw); - const waiter = ws.waiters.shift(); - if (waiter) waiter(msg); - else ws.queue.push(msg); - }); - ws.next = () => new Promise((resolve) => { - if (ws.queue.length) resolve(ws.queue.shift()); - else ws.waiters.push(resolve); - }); - ws.say = (obj) => ws.send(JSON.stringify(obj)); - return new Promise((resolve, reject) => { - ws.on('open', () => resolve(ws)); - ws.on('error', reject); - }); -} - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - // ------------------------------------------------------------------ TURN test('/config firma credenciales TURN que caducan', async () => { diff --git a/test/relay.test.mjs b/test/relay.test.mjs index e4b4efd..f49385a 100644 --- a/test/relay.test.mjs +++ b/test/relay.test.mjs @@ -12,17 +12,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; -import net from 'node:net'; import path from 'node:path'; import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { receiveFromRelay, RELAY_ACK_EVERY } from '../cli/src/transfer.js'; +import { startServer } from './helpers.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const CLI = path.join(ROOT, 'cli', 'src', 'cli.js'); -const SERVER = path.join(ROOT, 'server', 'index.js'); const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ''); const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); @@ -81,32 +80,6 @@ test('el receptor por relay acusa recibo cada 2 MB y confirma el final', async ( // ------------------------------------- transferencia CLI -> CLI (extremo a extremo) -function freePort() { - return new Promise((resolve) => { - const srv = net.createServer(); - srv.listen(0, '127.0.0.1', () => { - const { port } = srv.address(); - srv.close(() => resolve(port)); - }); - }); -} - -async function startSignalingServer() { - const port = await freePort(); - const proc = spawn(process.execPath, [SERVER], { - env: { ...process.env, PORT: String(port) }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('el servidor no arrancó')), 10_000); - proc.stdout.on('data', (d) => { - if (d.toString().includes('Drop escuchando')) { clearTimeout(timer); resolve(); } - }); - proc.on('error', reject); - }); - return { proc, url: `http://127.0.0.1:${port}` }; -} - /** Lanza el CLI y deja esperar a que aparezca algo en su salida. */ function runCli(args) { const proc = spawn(process.execPath, [CLI, ...args], { @@ -133,7 +106,7 @@ function runCli(args) { } test('relay CLI -> CLI entrega un archivo por encima de la ventana de 8 MB', { timeout: 120_000 }, async () => { - const server = await startSignalingServer(); + const server = await startServer(); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'drop-e2e-')); const outDir = path.join(work, 'destino'); fs.mkdirSync(outDir); @@ -144,13 +117,13 @@ test('relay CLI -> CLI entrega un archivo por encima de la ventana de 8 MB', { t const src = path.join(work, 'grande.bin'); fs.writeFileSync(src, body); - const sender = runCli(['send', src, '--server', server.url, '--relay']); + const sender = runCli(['send', src, '--server', server.http, '--relay']); let receiver = null; try { const [, code] = await sender.waitFor(/Código:\s+(\d{4}(?:-[a-z]+){4})/, 20_000); - receiver = runCli(['recv', code, '--server', server.url, '--relay', '-o', outDir]); + receiver = runCli(['recv', code, '--server', server.http, '--relay', '-o', outDir]); const exitCode = await receiver.exited; assert.equal(exitCode, 0, `el receptor falló:\n${receiver.output}\n--- emisor ---\n${sender.output}`); diff --git a/test/signaling.test.mjs b/test/signaling.test.mjs index 7dd6b7e..3c8e39a 100644 --- a/test/signaling.test.mjs +++ b/test/signaling.test.mjs @@ -1,40 +1,34 @@ // Prueba del servidor de emparejamiento: codigos, reenvio de SDP/ICE y desconexiones. -// Ejecutar con: npm test (necesita el servidor levantado en PORT o 3000) -import test from 'node:test'; +// Ejecutar con: npm test +// +// La suite arranca su propio servidor en un puerto efimero y lo apaga al acabar, +// asi que no hace falta levantar nada a mano. `DROP_URL` sigue sirviendo para +// apuntar a uno externo: en ese caso hay que darle tambien un +// `DROP_ALLOWED_ORIGINS` que incluya el origen que se prueba mas abajo. +import test, { before, after } from 'node:test'; import assert from 'node:assert/strict'; import WebSocket from 'ws'; -const URL = process.env.DROP_URL || 'ws://localhost:3000'; - -function open(options) { - const ws = new WebSocket(URL, options); - ws.queue = []; - ws.waiters = []; - ws.on('message', (raw) => { - const msg = JSON.parse(raw); - const waiter = ws.waiters.shift(); - if (waiter) waiter(msg); - else ws.queue.push(msg); - }); - ws.next = () => new Promise((resolve) => { - if (ws.queue.length) resolve(ws.queue.shift()); - else ws.waiters.push(resolve); - }); - ws.say = (obj) => ws.send(JSON.stringify(obj)); - // Los avisos de `guest-gone` se cuelan entre medias cuando el servidor echa a - // un receptor: para afirmar sobre un mensaje concreto hay que saltarse el ruido. - ws.until = async (pred) => { - for (let i = 0; i < 20; i++) { - const msg = await ws.next(); - if (pred(msg)) return msg; - } - throw new Error('no llego el mensaje esperado'); - }; - return new Promise((resolve, reject) => { - ws.on('open', () => resolve(ws)); - ws.on('error', reject); - }); -} +import { startServer, open as openSocket } from './helpers.mjs'; + +let server = null; +let URL = process.env.DROP_URL || ''; +// El origen que el servidor acepta por defecto, derivado de su puerto. +let ALLOWED_ORIGIN = process.env.DROP_TEST_ORIGIN || ''; + +before(async () => { + if (URL) { // servidor externo: no levantamos nada + if (!ALLOWED_ORIGIN) ALLOWED_ORIGIN = `http://localhost:${URL.split(':')[2] || '80'}`; + return; + } + server = await startServer(); + URL = server.ws; + if (!ALLOWED_ORIGIN) ALLOWED_ORIGIN = server.origin; +}); + +after(() => { server?.stop(); }); + +const open = (options) => openSocket(URL, options); test('el emisor recibe un identificador de sala y el receptor puede unirse', async () => { const host = await open(); @@ -223,12 +217,6 @@ test('un receptor no alcanza a otro de una sala distinta', async () => { host1.close(); host2.close(); a.close(); b.close(); }); -// El origen por defecto que acepta el servidor cuando no se le configura otro. -// Tiene que cuadrar con el puerto de URL: si se prueba contra otro servidor con -// DROP_URL, hay que darle tambien un DROP_ALLOWED_ORIGINS que lo incluya. -const ALLOWED_ORIGIN = process.env.DROP_TEST_ORIGIN - || `http://localhost:${URL.split(':')[2] || '80'}`; - test('el servidor rechaza un origen que no esta en la lista', async () => { // Una pagina no puede falsear su Origin, asi que esto es lo que impide que una // web cualquiera abra salas con el navegador de quien la visita. diff --git a/test/version.test.mjs b/test/version.test.mjs new file mode 100644 index 0000000..88e1860 --- /dev/null +++ b/test/version.test.mjs @@ -0,0 +1,45 @@ +// La version del CLI sale del package.json, y de ningun otro sitio. +// +// Estuvo escrita a mano en dos: `const VERSION` en cli/src/cli.js y el +// package.json. No es cosmetico: `drop update` compara la release de GitHub +// contra esa constante, asi que una constante vieja deja al CLI creyendose +// desactualizado para siempre -- reinstalando en bucle -- o al reves, sin +// enterarse nunca de una version nueva. +// +// Estos casos vigilan que no vuelva a haber una segunda copia, y que los +// enlaces de descarga del README apunten a la version que se va a publicar, +// que es la otra cosa que se quedaba atras al sacar una release con prisa. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import { ROOT } from './helpers.mjs'; + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const CLI = path.join(ROOT, 'cli', 'src', 'cli.js'); + +test('`drop --version` dice lo que dice el package.json', () => { + const out = execFileSync(process.execPath, [CLI, '--version'], { encoding: 'utf8' }); + assert.equal(out.trim(), `drop v${pkg.version}`); +}); + +test('cli.js no tiene una version escrita a mano', () => { + const source = fs.readFileSync(CLI, 'utf8'); + const hardcoded = source.match(/const VERSION\s*=\s*['"`]/); + assert.equal(hardcoded, null, 'la version tiene que salir del package.json, no de un literal'); +}); + +// Los enlaces de descarga son los unicos sitios del README donde la version es +// la que se publica: las menciones en prosa ("a partir de la v0.5.2") hablan de +// una version concreta a proposito y no se tocan. +test('los enlaces de descarga del README apuntan a la version actual', () => { + const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8'); + const links = [...readme.matchAll(/releases\/download\/v([0-9][^/]*)\//g)].map((m) => m[1]); + + assert.ok(links.length > 0, 'el README deberia tener enlaces de descarga'); + for (const version of new Set(links)) { + assert.equal(version, pkg.version, `el README enlaza a la v${version} y el package.json dice ${pkg.version}`); + } +});