/** * Caché local de artefactos de CloudRestoreAS. * * Lo crítico aquí es que un artefacto corrupto NUNCA llegue a tener nombre definitivo: el * rename solo ocurre después de que el sha256 cuadra. Si eso falla, el panel empujaría un * binario roto a un servidor de producción. También se prueba el anti-traversal, porque * version y fileName se usan para construir rutas. */ import { createHash } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { env } from '$env/dynamic/private'; import { ArtifactError, artifactPath, cacheDir, cacheUsage, ensureCached, isCached, isSafeFileName, isSafeVersion, pruneCache, removeCached, selectPrunableVersions } from './cras-artifacts'; const originalFetch = globalThis.fetch; let tmpRoot: string; function sha256Of(text: string): string { return createHash('sha256').update(text).digest('hex'); } /** Stub de la descarga de Gitea: responde el contenido indicado como stream. */ function stubDownload(content: string, status = 200) { globalThis.fetch = vi.fn(async () => { if (status !== 200) return new Response('', { status }); return new Response(content, { status: 200, headers: { 'content-length': String(Buffer.byteLength(content)) } }); }) as unknown as typeof fetch; } beforeEach(async () => { tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cras-cache-')); env.CRAS_RELEASES_DIR = tmpRoot; env.GITEA_TOKEN = 'token-de-prueba'; env.GITEA_BASE_URL = 'https://git.ejemplo.test'; }); afterEach(async () => { globalThis.fetch = originalFetch; await fs.rm(tmpRoot, { recursive: true, force: true }); for (const key of ['CRAS_RELEASES_DIR', 'GITEA_TOKEN', 'GITEA_BASE_URL', 'CRAS_CACHE_KEEP_VERSIONS']) { delete env[key]; } vi.restoreAllMocks(); }); describe('validación de nombres', () => { it('acepta versiones y nombres de artefacto reales', () => { expect(isSafeVersion('1.1.0')).toBe(true); expect(isSafeVersion('26.7.1.4')).toBe(true); expect(isSafeFileName('CloudRestoreAS-1.1.0-linux-x86_64.tar.gz')).toBe(true); expect(isSafeFileName('SHA256SUMS')).toBe(true); }); it('rechaza intentos de salir de la caché', () => { for (const version of ['..', '../etc', '1.1.0/../..', '', 'latest']) { expect(isSafeVersion(version), version).toBe(false); } for (const file of ['../evil', '/etc/passwd', 'a/b.zip', '..', '.oculto', 'con espacio.zip']) { expect(isSafeFileName(file), file).toBe(false); } }); it('artifactPath lanza ArtifactError ante entradas inseguras', () => { expect(() => artifactPath('../etc', 'x.zip')).toThrow(ArtifactError); expect(() => artifactPath('1.1.0', '../../evil')).toThrow(ArtifactError); }); it('artifactPath resuelve dentro de la caché', () => { const p = artifactPath('1.1.0', 'a.tar.gz'); expect(p).toBe(path.join(cacheDir(), '1.1.0', 'a.tar.gz')); expect(p.startsWith(path.resolve(cacheDir()) + path.sep)).toBe(true); }); }); describe('ensureCached', () => { const FILE = 'CloudRestoreAS-1.1.0-linux-x86_64.tar.gz'; const CONTENT = 'contenido-del-artefacto'; it('descarga, verifica el sha256 y deja el archivo definitivo', async () => { stubDownload(CONTENT); const result = await ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT)); expect(result.downloaded).toBe(true); expect(result.size).toBe(Buffer.byteLength(CONTENT)); expect(await fs.readFile(result.path, 'utf8')).toBe(CONTENT); expect(await isCached('1.1.0', FILE)).toBe(true); }); it('no re-descarga si ya está en caché', async () => { stubDownload(CONTENT); await ensureCached('1.1.0', FILE, sha256Of(CONTENT)); const spy = vi.fn(); globalThis.fetch = spy as unknown as typeof fetch; const second = await ensureCached('1.1.0', FILE, sha256Of(CONTENT)); expect(second.downloaded).toBe(false); expect(spy).not.toHaveBeenCalled(); }); it('con sha256 distinto NO deja el archivo definitivo y borra el temporal', async () => { // Este es el caso que evita empujar un binario corrupto a producción. stubDownload(CONTENT); await expect(ensureCached('1.1.0', FILE, sha256Of('otro-contenido'))).rejects.toThrow( /sha256/i ); expect(await isCached('1.1.0', FILE)).toBe(false); const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []); expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]); }); it('con tamaño distinto al declarado también aborta', async () => { stubDownload(CONTENT); await expect( ensureCached('1.1.0', FILE, sha256Of(CONTENT), Buffer.byteLength(CONTENT) + 100) ).rejects.toThrow(/tama/i); expect(await isCached('1.1.0', FILE)).toBe(false); }); it('sin sha256 registrado se niega a descargar 270 MB a ciegas', async () => { const spy = vi.fn(); globalThis.fetch = spy as unknown as typeof fetch; await expect(ensureCached('1.1.0', FILE, null)).rejects.toMatchObject({ status: 409 }); expect(spy).not.toHaveBeenCalled(); }); it('con un sha256 mal formado también se niega', async () => { const spy = vi.fn(); globalThis.fetch = spy as unknown as typeof fetch; await expect(ensureCached('1.1.0', FILE, 'abc123')).rejects.toMatchObject({ status: 409 }); expect(spy).not.toHaveBeenCalled(); }); it('un fallo de descarga limpia el temporal', async () => { stubDownload('', 500); await expect(ensureCached('1.1.0', FILE, sha256Of(CONTENT))).rejects.toThrow(); const leftovers = await fs.readdir(path.join(tmpRoot, '1.1.0')).catch(() => []); expect(leftovers.filter((f) => f.includes('.part'))).toEqual([]); }); }); describe('cacheUsage y removeCached', () => { it('mide el uso por versión e ignora los .part', async () => { await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true }); await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x'.repeat(100)); await fs.writeFile(path.join(tmpRoot, '1.1.0', 'b.zip'), 'y'.repeat(50)); // Un temporal de una descarga en curso no debe contar como espacio consumido útil. await fs.writeFile(path.join(tmpRoot, '1.1.0', 'c.zip.123.part'), 'z'.repeat(999)); const usage = await cacheUsage(); expect(usage.total_bytes).toBe(150); expect(usage.versions).toEqual([{ version: '1.1.0', bytes: 150, files: 2 }]); }); it('ignora carpetas que no son versiones válidas', async () => { await fs.mkdir(path.join(tmpRoot, 'basura'), { recursive: true }); await fs.writeFile(path.join(tmpRoot, 'basura', 'x'), 'x'.repeat(10)); expect((await cacheUsage()).total_bytes).toBe(0); }); it('devuelve vacío si la caché no existe todavía', async () => { env.CRAS_RELEASES_DIR = path.join(tmpRoot, 'no-existe'); expect(await cacheUsage()).toEqual({ total_bytes: 0, versions: [] }); }); it('removeCached borra el archivo', async () => { await fs.mkdir(path.join(tmpRoot, '1.1.0'), { recursive: true }); await fs.writeFile(path.join(tmpRoot, '1.1.0', 'a.tar.gz'), 'x'); await removeCached('1.1.0', 'a.tar.gz'); expect(await isCached('1.1.0', 'a.tar.gz')).toBe(false); }); }); describe('pruneCache', () => { async function seed(versions: string[]) { for (const v of versions) { await fs.mkdir(path.join(tmpRoot, v), { recursive: true }); await fs.writeFile(path.join(tmpRoot, v, 'a.tar.gz'), 'x'.repeat(10)); } } it('conserva las N más recientes por orden numérico de versión', async () => { env.CRAS_CACHE_KEEP_VERSIONS = '2'; // 1.10.0 es más nueva que 1.9.0: un orden lexicográfico borraría la equivocada. await seed(['1.8.0', '1.9.0', '1.10.0']); const removed = await pruneCache([]); expect(removed).toEqual(['1.8.0']); expect((await cacheUsage()).versions.map((v) => v.version).sort()).toEqual([ '1.10.0', '1.9.0' ]); }); it('nunca borra una versión fijada, aunque sea vieja', async () => { // Dejar sin artefacto local a la versión activa obligaría a re-descargar 270 MB en // plena instalación. env.CRAS_CACHE_KEEP_VERSIONS = '1'; await seed(['1.8.0', '1.9.0', '1.10.0']); const removed = await pruneCache(['1.8.0']); expect(removed).not.toContain('1.8.0'); expect(await isCached('1.8.0', 'a.tar.gz')).toBe(true); }); it('no borra nada si cabe dentro del límite', async () => { env.CRAS_CACHE_KEEP_VERSIONS = '5'; await seed(['1.1.0', '1.2.0']); expect(await pruneCache([])).toEqual([]); }); }); describe('selectPrunableVersions', () => { // Es la previsualización que ve el operador antes de confirmar la poda: tiene que decidir con // el mismo criterio que pruneCache, o el modal prometería borrar algo distinto de lo que borra. const usage = [ { version: '1.8.0', bytes: 100 }, { version: '1.9.0', bytes: 200 }, { version: '1.10.0', bytes: 400 } ]; it('conserva las N más nuevas en orden numérico, no lexicográfico', () => { // Con orden de texto "1.10.0" < "1.9.0" y se borraría la más nueva. expect(selectPrunableVersions(usage, [], 2)).toEqual({ versions: ['1.8.0'], bytes: 100 }); expect(selectPrunableVersions(usage, [], 1)).toEqual({ versions: ['1.9.0', '1.8.0'], bytes: 300 }); }); it('nunca propone una versión fijada y no la cuenta contra el límite', () => { expect(selectPrunableVersions(usage, ['1.8.0'], 1)).toEqual({ versions: ['1.9.0'], bytes: 200 }); }); it('suma exactamente los bytes de las versiones elegidas', () => { const plan = selectPrunableVersions(usage, [], 0); expect(plan.versions).toEqual(['1.10.0', '1.9.0', '1.8.0']); expect(plan.bytes).toBe(700); }); it('con menos versiones que el límite el plan es vacío: el clic no haría nada', () => { // El control debe decir "Nada que liberar" en vez de ofrecer un borrado que es un no-op. expect(selectPrunableVersions(usage, [], 5)).toEqual({ versions: [], bytes: 0 }); expect(selectPrunableVersions([], [], 3)).toEqual({ versions: [], bytes: 0 }); }); it('ignora carpetas que no son versiones válidas', () => { const dirty = [...usage, { version: '../etc', bytes: 999 }, { version: 'tmp', bytes: 5 }]; expect(selectPrunableVersions(dirty, [], 3).versions).toEqual([]); }); });