mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
fix: enforce 1:1 compose path mapping for Pilot agent mounts (#1516)
* fix: enforce 1:1 compose path mapping for Pilot agent mounts Pilot enrollment now generates validated 1:1 bind mounts so every agent path maps to a unique compose directory. Persisted agent paths reconcile during startup to catch drift. Unsafe relative-bind redeploys are blocked before container removal to prevent path escapes. - Add composePathMapping utility with strict path validation - Generate COMPOSE_DIR and validated mounts during Pilot enrollment - Reconcile persisted agent paths during startup bootstrap - Block redeploy when a relative-bind mount would escape the compose root - Default Pilot UI path to /opt/docker/sencho - Update multi-node and pilot-agent documentation - Add regression tests for enrollment, bootstrap, compose-service, and environment-check paths * fix: update E2E enrollment regexes for YAML-quoted token values
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import path from 'path';
|
||||
|
||||
export interface BindPathMapping {
|
||||
source: string;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
if (value === '/' || /^[A-Za-z]:[\\/]?$/.test(value)) return value;
|
||||
return value.replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function isAtOrBelow(candidate: string, base: string): boolean {
|
||||
const resolvedCandidate = path.resolve(candidate);
|
||||
const resolvedBase = path.resolve(base);
|
||||
|
||||
// After resolution, verify the candidate still starts with the base followed
|
||||
// by a separator (or is an exact match). This catches `..` segments that
|
||||
// would pass a naive string-prefix check but escape the intended directory.
|
||||
if (resolvedCandidate === resolvedBase) return true;
|
||||
// Filesystem root: any resolved absolute path is at or below it.
|
||||
if (resolvedBase === path.resolve('/')) return path.isAbsolute(resolvedCandidate);
|
||||
if (/^[A-Za-z]:[\\/]?$/.test(resolvedBase)) {
|
||||
return resolvedCandidate.toLowerCase().startsWith(resolvedBase.slice(0, 2).toLowerCase());
|
||||
}
|
||||
return resolvedCandidate.startsWith(resolvedBase + path.sep);
|
||||
}
|
||||
|
||||
/** Resolve a container-visible path to the corresponding host bind path. */
|
||||
export function resolveHostBindPath(
|
||||
containerPath: string,
|
||||
mounts: ReadonlyArray<BindPathMapping>,
|
||||
): string | null {
|
||||
const target = normalizePath(containerPath);
|
||||
const match = mounts
|
||||
.filter((mount) => isAtOrBelow(target, normalizePath(mount.destination)))
|
||||
.sort((a, b) => normalizePath(b.destination).length - normalizePath(a.destination).length)[0];
|
||||
if (!match) return null;
|
||||
|
||||
const destination = normalizePath(match.destination);
|
||||
const source = normalizePath(match.source);
|
||||
const suffix = target.slice(destination.length).replace(/^[\\/]+/, '');
|
||||
if (!suffix) return source;
|
||||
const separator = source.endsWith('/') || source.endsWith('\\') ? '' : '/';
|
||||
return `${source}${separator}${suffix}`;
|
||||
}
|
||||
|
||||
export function pathsMatch(left: string, right: string): boolean {
|
||||
return normalizePath(left) === normalizePath(right);
|
||||
}
|
||||
Reference in New Issue
Block a user