diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index 60610a3..35916fd 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -31,6 +31,7 @@ import { alignWindowsTask, checkWindowsArtifactUpdateSupport, normalizeWindowsPath, + probeInstallerUpdateSupport, probeWindowsAgentProcess, probeWindowsElevation, probeWindowsTask, @@ -481,9 +482,13 @@ describe('comparación de rutas de Windows', () => { * agente viejo y sin agente nuevo. */ describe('checkWindowsArtifactUpdateSupport', () => { + const SI = { answer: 'si', detail: '' } as const; + const NO = { answer: 'no', detail: '' } as const; + const NOSE = { answer: 'desconocido', detail: 'Get-Content: acceso denegado' } as const; + it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => { try { - checkWindowsArtifactUpdateSupport('update', false, '1.1.3', 'Principal'); + checkWindowsArtifactUpdateSupport('update', NO, '1.1.3', 'Principal'); throw new Error('se esperaba que lanzara'); } catch (e) { const err = e as InstallError; @@ -500,14 +505,93 @@ describe('checkWindowsArtifactUpdateSupport', () => { it('INSTALAR limpio con el mismo artefacto solo avisa', () => { // Ahí no hay agente en marcha que perder, así que negarse solo estorbaría. - const aviso = checkWindowsArtifactUpdateSupport('install', false, '1.1.3', 'Principal'); + const aviso = checkWindowsArtifactUpdateSupport('install', NO, '1.1.3', 'Principal'); expect(aviso).toContain('1.1.3'); expect(aviso).toMatch(/Se instala igual/); }); it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => { - expect(checkWindowsArtifactUpdateSupport('update', true, '1.1.4', 'Principal')).toBe(''); - expect(checkWindowsArtifactUpdateSupport('install', true, '1.1.4', 'Principal')).toBe(''); + expect(checkWindowsArtifactUpdateSupport('update', SI, '1.1.4', 'Principal')).toBe(''); + expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe(''); + }); + + it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => { + // La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se + // decidía un 409. Con un artefacto correcto, eso era un candado permanente. + const aviso = checkWindowsArtifactUpdateSupport('update', NOSE, '1.1.4', 'Principal'); + expect(aviso).toMatch(/No se pudo determinar/); + // Y dice POR QUÉ no se pudo, que es lo que evita adivinar la próxima vez. + expect(aviso).toContain('acceso denegado'); + }); + + it('tampoco bloquea al instalar cuando no se pudo determinar', () => { + expect(checkWindowsArtifactUpdateSupport('install', NOSE, '1.1.4', 'Principal')).toMatch( + /No se pudo determinar/ + ); + }); +}); + +/** + * La sonda que lee el instalador del artefacto. + * + * Lo que se protege aquí es la distinción entre "no lo declara" y "no pude leerlo". La versión + * anterior preguntaba por `(Get-Command …).Parameters`, funcionaba en una máquina de desarrollo y + * fallaba en el servidor real; como cualquier fallo se leía como "no", el panel acabó rechazando un + * artefacto correcto. + */ +describe('probeInstallerUpdateSupport', () => { + const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1'; + + it('lee el texto del script sin resolver un solo tipo .NET', async () => { + const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('si'); + + const script = Buffer.from( + commands[0].split('-EncodedCommand ')[1] ?? '', + 'base64' + ).toString('utf16le'); + // Nada de reflexión ni de .NET: en Constrained Language Mode se prohíbe resolver tipos y + // llamar métodos estáticos, mientras los cmdlets siguen funcionando. Esta prueba es la que + // impide volver a caer ahí — primero con (Get-Command …).Parameters, después con + // [regex]::IsMatch, las dos veces por el mismo motivo. + expect(script).not.toContain('Get-Command'); + expect(script).not.toContain('.Parameters'); + expect(script).not.toContain('[regex]'); + expect(script).not.toMatch(/\[[A-Za-z][A-Za-z0-9.]*\]::/); + expect(script).toContain('Select-String'); + // Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente. + expect(script).toContain('switch'); + }); + + it('un "no" limpio sigue siendo un no', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'no' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('no'); + }); + + it('código de salida distinto de cero es DESCONOCIDO, nunca "no"', async () => { + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '', stderr: 'AmsiScanBuffer bloqueó' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toContain('AmsiScanBuffer'); + }); + + it('stdout vacío con código 0 también es DESCONOCIDO', async () => { + // Es la forma exacta en que fallaba la sonda vieja: sin salida, y leído como respuesta. + const { sftp } = fakeSftp(() => ({ code: 0, stdout: ' ' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); + }); + + it('el archivo que no existe se distingue de un instalador antiguo', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' })); + const r = await probeInstallerUpdateSupport(sftp as never, RUTA); + expect(r.answer).toBe('desconocido'); + expect(r.detail).toBe('no existe el archivo'); + }); + + it('una salida inesperada no se interpreta como respuesta', async () => { + const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'WARNING: algo raro' })); + expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido'); }); }); @@ -539,12 +623,22 @@ describe('sondas de ruta en Windows', () => { it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => { const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM'); expect(await probeWindowsTask(sftp as never)).toEqual({ + answered: true, exists: true, execute: 'C:\\Otra\\CloudRestoreAS.exe', principal: 'SYSTEM' }); }); + it('una sonda que no responde no significa "no hay tarea"', async () => { + // Con `answered: false` el llamador sabe que no se sabe. Antes, un comando fallido se leía + // como "no hay tarea registrada" y de ahí salían decisiones sobre elevación y realineación. + const { sftp } = fakeSftp(() => ({ code: 1, stdout: '' })); + const r = await probeWindowsTask(sftp as never); + expect(r.answered).toBe(false); + expect(r.exists).toBe(false); + }); + it('probeWindowsTask sin tarea registrada', async () => { const { sftp } = psFake('no||'); const r = await probeWindowsTask(sftp as never); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 9d6d31d..acf79d1 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1041,7 +1041,7 @@ async function verifyLinuxDeployment( // Windows // ============================================================================ -export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado'; +export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido'; export interface WindowsPrivilege { elevation: WindowsElevation; @@ -1050,6 +1050,28 @@ export interface WindowsPrivilege { detail: string; } +/** + * Modo de lenguaje de PowerShell en el destino. + * + * Existe porque en `ConstrainedLanguage` —lo que imponen AppLocker o WDAC, habituales en servidores + * endurecidos— PowerShell prohíbe resolver tipos .NET y llamar a métodos estáticos, mientras los + * cmdlets y `& script.ps1` siguen funcionando. Eso convierte fallos en sitios sorprendentes: fue la + * hipótesis principal cuando `(Get-Command …).Parameters` respondía en una máquina de desarrollo y + * no en el servidor real, y `install.ps1` todavía usa varias construcciones que ahí no funcionarían + * (`[System.IO.Path]::GetFullPath`, `New-Object System.Text.UTF8Encoding`, `[TimeSpan]::Zero`…). + * + * Se registra en la bitácora del run SIEMPRE, cueste lo que cueste averiguarlo, para dejar de + * diagnosticar a ciegas: sin este dato cada fallo raro en Windows arranca con una ronda de + * suposiciones. Devuelve cadena vacía si ni siquiera esto se pudo consultar. + */ +export async function probeWindowsLanguageMode(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded('Write-Output $ExecutionContext.SessionState.LanguageMode') + ); + return probe.code === 0 ? probe.stdout.trim() : ''; +} + /** * Sonda de elevación en Windows. * @@ -1075,7 +1097,22 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise p.trim()).filter(Boolean); const wanted = windowsAgentExe(prefix); return { + answered: true, running: true, paths, fromPrefix: paths.some((p) => sameWindowsPath(p, wanted)) @@ -1187,6 +1236,8 @@ export async function probeWindowsAgentProcess( } export interface WindowsTaskProbe { + /** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */ + answered: boolean; exists: boolean; /** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */ execute: string; @@ -1221,14 +1272,93 @@ export async function probeWindowsTask(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded( + `$p = '${installerPath}'; ` + + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + + 'try { ' + + // Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET. + // La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por + // tanto de lo primero que bloquea Constrained Language Mode — el modo que se + // sospecha en el servidor donde falló `(Get-Command …).Parameters`. Habría + // degradado a 'desconocido' en vez de responder, que no rompe nada pero tampoco + // sirve de nada. + // `-ErrorAction Stop` NO es opcional: Select-String emite un error NO TERMINANTE + // cuando no puede leer el archivo, así que sin él el catch no se dispara, -Quiet + // devuelve falso y el fallo de lectura se reporta como 'no'. Es decir, exactamente + // el defecto que esta función existe para eliminar. Lo destapó probar la ruta de + // fallo con PowerShell real, no las pruebas unitarias. + "if (Select-String -LiteralPath $p -Pattern '\\[switch\\]\\s*\\$UpdateInPlace' " + + '-Quiet -ErrorAction Stop) ' + + "{ Write-Output 'si' } else { Write-Output 'no' } } " + + "catch { Write-Output ('desconocido|' + $_.Exception.Message) }" + ) + ); + + const salida = probe.stdout.trim(); + // El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso + // como una respuesta es precisamente el error que se está corrigiendo. + if (probe.code !== 0 || !salida) { + return { + answer: 'desconocido', + detail: + `la sonda no respondió (código ${probe.code})` + + (probe.stderr ? `: ${truncate(probe.stderr)}` : '') + }; + } + if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' }; + return { + answer: 'desconocido', + detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida) + }; +} + /** * ¿Se puede desplegar este artefacto en Windows sin romper el servidor? * @@ -1250,11 +1380,22 @@ export async function probeWindowsTask(sftp: SftpClient): Promise