Files
PANEL_BASES_ANEXO24/src/lib/cras-version.ts
hreyes 5b83a57d18 fix(cras-install): dejar de rechazar el layout normal de carpetas de trabajo
isInsideWorkFolder miraba la relación en los dos sentidos, así que también
rechazaba que las carpetas de trabajo colgaran de la de instalación. Ese es el
layout NORMAL, no un error: constants.py del agente define
DIR_ENTRADA = APP_DIR / "Entrada", así que una instalación correcta en
/opt/cloudrestoreas reporta input_folder = /opt/cloudrestoreas/Entrada.

Efecto: ninguna instalación ni actualización podía pasar, en Linux ni en
Windows. Fallaba en resolveInstallPath —antes de abrir el run, así que no
quedaba nada en la bitácora— con un mensaje que decía justo lo contrario de lo
que ocurría: "la ruta está dentro de una carpeta de trabajo". Solo se libraban
las instalaciones fuera de la ruta por omisión, por accidente del nombre.

Se conserva el peligro que el guard existe para atajar: el binario dentro de
Entrada/Procesados, donde el agente lo tomaría por un respaldo a procesar.

La función no tenía ninguna prueba. Se agregan las dos direcciones, el caso
exacto, la normalización de separador/caja/barra final y el prefijo que no es
de carpeta (/srv/entradas vs /srv/entrada).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:25:01 -06:00

249 lines
10 KiB
TypeScript

/**
* Comparación de versiones de CloudRestoreAS y normalización de plataforma.
*
* Módulo PURO (sin acceso a red ni a BD) para que sea testeable y usable tanto en el
* servidor como en el navegador. Es la pieza que decide si un servidor tiene una versión
* vieja, así que un error aquí se traduce en actualizaciones que nunca se ofrecen o que se
* ofrecen en reversa.
*/
/** Plataformas para las que se publican artefactos (espejo del CHECK de cras_releases). */
export const CRAS_PLATFORMS = ['windows', 'linux'] as const;
export type CrasPlatform = (typeof CRAS_PLATFORMS)[number];
export const DEFAULT_ARCH = 'x86_64';
export function isCrasPlatform(value: unknown): value is CrasPlatform {
return typeof value === 'string' && (CRAS_PLATFORMS as readonly string[]).includes(value);
}
/**
* Versión válida: solo puntos y números (1.1.0, 26.7.1.4). CloudRestoreAS lo garantiza en
* package-release.sh, que aborta el empaquetado si el formato no cumple.
*/
const VERSION_RE = /^\d+(\.\d+){0,3}$/;
export function isValidVersion(value: unknown): boolean {
return typeof value === 'string' && VERSION_RE.test(value.trim());
}
/** Convierte "1.10.2" en [1, 10, 2]. Devuelve null si no es una versión comparable. */
export function parseVersion(value: string): number[] | null {
const raw = String(value ?? '').trim();
if (!VERSION_RE.test(raw)) return null;
return raw.split('.').map((part) => Number.parseInt(part, 10));
}
/**
* Orden numérico por componente, rellenando con ceros: "1.2" == "1.2.0" y "1.10" > "1.9"
* (una comparación de cadenas daría lo contrario, que es el error clásico).
*
* Devuelve -1 si a < b, 0 si son equivalentes, 1 si a > b, y null si alguna no es
* comparable — el llamador debe tratar null como "no sé", nunca como "iguales".
*/
export function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) return null;
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const x = left[i] ?? 0;
const y = right[i] ?? 0;
if (x < y) return -1;
if (x > y) return 1;
}
return 0;
}
/**
* ¿`candidate` es más nueva que `installed`?
*
* Sin versión instalada (servidor sin agente todavía) cuenta como que sí hay algo que
* instalar. Si alguna versión no es comparable devuelve false: es preferible no ofrecer
* una actualización que ofrecer un downgrade por una comparación inválida.
*/
export function isNewer(candidate: string, installed: string | null | undefined): boolean {
if (!isValidVersion(candidate)) return false;
const current = (installed ?? '').trim();
if (!current) return true;
return compareVersions(candidate, current) === 1;
}
/**
* Normaliza el texto libre de `restore_targets.os` a una plataforma.
*
* Ese campo lo captura a mano el operador ("Windows Server 2019", "Ubuntu 22.04"), así que
* solo sirve como respaldo para la PRIMERA instalación, cuando el agente todavía no ha
* reportado su plataforma real. Devuelve null si no se puede decidir — la UI entonces pide
* elegir a mano en lugar de adivinar y mandar el binario equivocado.
*/
export function osToPlatform(os: string | null | undefined): CrasPlatform | null {
const text = (os ?? '').trim().toLowerCase();
if (!text) return null;
if (text.includes('windows') || text.includes('win server') || /\bwin\b/.test(text)) {
return 'windows';
}
const linuxHints = [
'linux',
'ubuntu',
'debian',
'centos',
'rhel',
'red hat',
'redhat',
'fedora',
'suse',
'rocky',
'alma',
'oracle linux'
];
if (linuxHints.some((hint) => text.includes(hint))) return 'linux';
return null;
}
/**
* Plataforma efectiva de un destino: lo que reportó el agente manda sobre el texto libre.
* `reported` viene de cloudrestore_status.platform y es autoritativo.
*/
export function effectivePlatform(
reported: string | null | undefined,
os: string | null | undefined
): CrasPlatform | null {
const value = (reported ?? '').trim().toLowerCase();
if (isCrasPlatform(value)) return value;
return osToPlatform(os);
}
/** Arquitectura efectiva; si el agente no la reportó se asume la única que se publica hoy. */
export function effectiveArch(reported: string | null | undefined): string {
const value = (reported ?? '').trim().toLowerCase();
return value || DEFAULT_ARCH;
}
// ============================================================================
// Ruta de instalación
//
// Es un espacio de rutas SEPARADO de `input_folder` (la carpeta que vigila el agente, que
// puede ser un share UNC en otra máquina) y de `remote_inbox_path`. La reporta el agente
// (`APP_DIR` = carpeta del ejecutable), y ahí vive `config/.env` con las rutas de trabajo
// que el operador haya personalizado. Instalar en la ruta equivocada crea una segunda
// instalación con la configuración default y deja huérfano el `.env` personalizado.
// ============================================================================
export const DEFAULT_INSTALL_PATHS: Record<CrasPlatform, string> = {
linux: '/opt/cloudrestoreas',
windows: 'C:\\Aduanasoft\\CloudRestoreAS'
};
/**
* Valida una ruta de instalación. Se aplica en serio porque el valor llega **por red** desde
* el agente y termina interpolado en un comando de shell y en un script de PowerShell.
*
* Se rechazan las rutas UNC a propósito: el bootstrap escribe `config/` junto al ejecutable, y
* una tarea programada como SYSTEM no ve shares de red, así que un agente instalado en un
* share no podría arrancar.
*/
export function isSafeInstallPath(path: unknown, platform: CrasPlatform): boolean {
const value = String(path ?? '').trim();
if (!value || value.length > 400) return false;
// Comillas, metacaracteres de shell y saltos de línea: fuera, sin excepciones.
if (/["'`$;&|<>\n\r*?()[\]{}!]/.test(value)) return false;
if (value.includes('..')) return false;
if (platform === 'windows') {
if (value.startsWith('\\\\')) return false; // UNC
return /^[A-Za-z]:\\[A-Za-z0-9 ._\\-]*$/.test(value);
}
if (value.startsWith('//')) return false; // equivalente POSIX de un share
return /^\/[A-Za-z0-9 ._/-]*$/.test(value);
}
/**
* Ruta efectiva: la que reportó el agente si es válida, o el default de la plataforma.
* Devuelve null si no se conoce la plataforma — sin ella no hay default que aplicar.
*/
export function effectiveInstallPath(
reported: string | null | undefined,
platform: CrasPlatform | null | undefined
): string | null {
if (!platform || !isCrasPlatform(platform)) return null;
const value = String(reported ?? '').trim();
if (value && isSafeInstallPath(value, platform)) {
return value.replace(/[/\\]+$/, '') || value;
}
return DEFAULT_INSTALL_PATHS[platform];
}
/** True si la ruta reportada por el agente es la default de su plataforma. */
export function isDefaultInstallPath(
path: string | null | undefined,
platform: CrasPlatform | null | undefined
): boolean {
if (!platform || !isCrasPlatform(platform)) return false;
return String(path ?? '').trim() === DEFAULT_INSTALL_PATHS[platform];
}
/**
* ¿La ruta de instalación cae dentro de una carpeta de trabajo del agente?
*
* Instalar el binario dentro de `Entrada`/`Procesados` sería grave: el agente vigila esa
* carpeta y trataría de procesar sus propios archivos como si fueran respaldos.
*
* La relación se mira en UN solo sentido, y eso es deliberado. La carpeta de trabajo **debajo**
* de la de instalación es el layout normal, no un error: `constants.py` del agente define
* `DIR_ENTRADA = APP_DIR / "Entrada"`, así que una instalación correcta en
* `/opt/cloudrestoreas` reporta `input_folder = /opt/cloudrestoreas/Entrada`. Cuando esto también
* rechazaba ese caso, ninguna instalación ni actualización podía pasar: fallaba en
* resolveInstallPath, antes de abrir el run, con un mensaje que decía justo lo contrario de lo
* que ocurría. Solo se salvaban las instalaciones fuera de la ruta por omisión, por accidente.
*/
export function isInsideWorkFolder(
installPath: string | null | undefined,
workFolders: (string | null | undefined)[]
): boolean {
const norm = (p: string | null | undefined) =>
String(p ?? '')
.trim()
.replace(/\\/g, '/')
.replace(/\/+$/, '')
.toLowerCase();
const target = norm(installPath);
if (!target) return false;
return workFolders.some((folder) => {
const base = norm(folder);
if (!base) return false;
return target === base || target.startsWith(`${base}/`);
});
}
/** Etiqueta legible para la UI. */
export function platformLabel(platform: string | null | undefined): string {
if (platform === 'windows') return 'Windows';
if (platform === 'linux') return 'Linux';
return 'Sin determinar';
}
/**
* ¿Se puede instalar un artefacto de `releasePlatform` en un destino cuya plataforma efectiva es
* `targetPlatform`?
*
* Cuando el panel NO pudo determinar la plataforma del destino no se adivina: se exige una
* confirmación explícita del operador (`ack`) que coincida con la plataforma del artefacto. Sin
* esto, el modal preseleccionaba el primer artefacto del catálogo y un solo clic podía mandar el
* .tar.gz de Linux a un Windows; el fallo aparecía hasta el destino, ya con la sesión SSH abierta
* y un run fallido en la bitácora.
*
* La misma función se usa en la UI (para habilitar el botón) y en la action `install` (como red
* de seguridad ante un formulario viejo o un POST a mano), así que la regla vive en un solo lugar.
*/
export function installPlatformVerdict(
targetPlatform: CrasPlatform | null | undefined,
releasePlatform: CrasPlatform,
ack: string | null | undefined
): 'ok' | 'mismatch' | 'needs-ack' {
if (targetPlatform) return targetPlatform === releasePlatform ? 'ok' : 'mismatch';
return (ack ?? '').trim().toLowerCase() === releasePlatform ? 'ok' : 'needs-ack';
}