fix(cras-install): sonda sin .NET y registrar el LanguageMode del destino

La sonda que decide si el instalador del artefacto soporta -UpdateInPlace usaba
[regex]::IsMatch, que es una llamada estatica y por tanto de lo primero que
bloquea Constrained Language Mode — el mismo motivo por el que fallo antes
(Get-Command …).Parameters en ese servidor y no en desarrollo. Habria degradado a
'desconocido' en vez de responder: no rompe, pero tampoco sirve. Pasa a
Select-String, un cmdlet puro sin resolucion de tipos.

`-ErrorAction Stop` NO es opcional, y esto lo destapo probar la ruta de FALLO con
PowerShell real, no las pruebas unitarias: Select-String emite un error NO
TERMINANTE cuando no puede leer el archivo, asi que sin el, el catch no se
dispara, -Quiet devuelve falso y un fallo de lectura se reporta como 'no' —
exactamente el defecto que esta funcion existe para eliminar.

Verificado contra PowerShell real los cuatro casos: instalador nuevo -> si,
instalador del zip 1.1.2 publicado -> no, archivo inexistente -> desconocido,
fallo de lectura -> desconocido.

Se anade probeWindowsLanguageMode y se registra SIEMPRE en el paso
precondiciones, con un aviso aparte cuando no es FullLanguage. Es el dato que
faltaba para dejar de diagnosticar a ciegas: install.ps1 todavia usa cinco
construcciones .NET que en modo restringido no funcionarian, y sin verlo en la
bitacora cada fallo raro en Windows empieza con una ronda de suposiciones.

La prueba nueva prohibe cualquier `[Tipo]::` en el script de la sonda. Se ha
caido dos veces en lo mismo; que lo impida una prueba y no la memoria.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 14:41:39 -06:00
parent bc1fcd8508
commit dd483da485
2 changed files with 59 additions and 9 deletions

View File

@@ -542,7 +542,7 @@ describe('checkWindowsArtifactUpdateSupport', () => {
describe('probeInstallerUpdateSupport', () => {
const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1';
it('lee el texto del script en vez de compilarlo', async () => {
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');
@@ -551,11 +551,15 @@ describe('probeInstallerUpdateSupport', () => {
commands[0].split('-EncodedCommand ')[1] ?? '',
'base64'
).toString('utf16le');
// Nada de reflexión: en Constrained Language Mode el acceso a .Parameters puede fallar
// mientras `& install.ps1` sigue funcionando, que es lo que se observó en el servidor.
// 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).toContain('Get-Content');
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');
});

View File

@@ -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<string> {
const probe = await execRemote(
sftp,
psEncoded('Write-Output $ExecutionContext.SessionState.LanguageMode')
);
return probe.code === 0 ? probe.stdout.trim() : '';
}
/**
* Sonda de elevación en Windows.
*
@@ -1301,10 +1323,19 @@ export async function probeInstallerUpdateSupport(
`$p = '${installerPath}'; ` +
"if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " +
'try { ' +
'$t = Get-Content -LiteralPath $p -Raw -ErrorAction Stop; ' +
// El [regex]::IsMatch evita depender de $matches y del operador -match, que en
// modo restringido puede comportarse distinto.
'if ([regex]::IsMatch($t, \'\\[switch\\]\\s*\\$UpdateInPlace\')) ' +
// 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) }"
)
@@ -1607,12 +1638,27 @@ async function installWindows(
if (needsElevation && privileged.elevation === 'token-filtrado') {
await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail);
}
// El modo de lenguaje se registra SIEMPRE, aunque todo vaya bien. Es barato y es el dato que
// faltaba: en ConstrainedLanguage varias construcciones de install.ps1 no funcionan, y sin
// verlo en la bitácora cada fallo raro en Windows empieza con una ronda de suposiciones.
const languageMode = await probeWindowsLanguageMode(sftp);
await appendInstallStep(
runId,
'precondiciones',
true,
`${systemEvidence}, privilegios=${privileged.label}`
`${systemEvidence}, privilegios=${privileged.label}` +
`, PowerShell=${languageMode || 'modo no determinado'}`
);
if (languageMode && languageMode !== 'FullLanguage') {
await appendInstallStep(
runId,
'aviso-modo-restringido',
true,
`PowerShell corre en ${languageMode}. Ahí se prohíbe resolver tipos .NET, y install.ps1 ` +
'usa varias construcciones que dependen de eso, así que puede fallar en sitios ' +
'inesperados aunque el artefacto sea correcto.'
);
}
if (request.mode === 'update') {
await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath);