fix(api): chemins de bibliothèques — validation dédiée, alias et unicité
Validation des chemins externalisée (library-path) avec codes d'erreur explicites, support d'alias LIBRARY_PATH_ALIASES pour traduire un chemin hôte vers le montage conteneur, rejet des doublons de chemin entre bibliothèques, documentation README/.env.example et docker-compose paramétrable via READABOOK_LIBRARY_HOST_PATH. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
72
apps/api/src/libraries/library-path.ts
Normal file
72
apps/api/src/libraries/library-path.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
||||
import { relative, resolve, sep } from "node:path";
|
||||
|
||||
export type LibraryPathAlias = {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type LibraryPathErrorCode = "LIBRARY_PATH_NOT_FOUND" | "LIBRARY_PATH_NOT_DIRECTORY" | "LIBRARY_PATH_NOT_READABLE";
|
||||
|
||||
export class LibraryPathValidationError extends Error {
|
||||
constructor(
|
||||
public readonly code: LibraryPathErrorCode,
|
||||
public readonly path: string
|
||||
) {
|
||||
super(messageForCode(code));
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLibraryPath(input: string, aliases: LibraryPathAlias[] = []): string {
|
||||
const candidates = candidatePaths(input, aliases);
|
||||
let firstError: LibraryPathValidationError | null = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stats = statSync(candidate);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new LibraryPathValidationError("LIBRARY_PATH_NOT_DIRECTORY", candidate);
|
||||
}
|
||||
accessSync(candidate, constants.R_OK | constants.X_OK);
|
||||
return realpathSync(candidate);
|
||||
} catch (error) {
|
||||
firstError ??= normalizePathError(error, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
throw firstError ?? new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", resolve(input));
|
||||
}
|
||||
|
||||
function candidatePaths(input: string, aliases: LibraryPathAlias[]): string[] {
|
||||
const resolved = resolve(input);
|
||||
const candidates = [resolved];
|
||||
|
||||
for (const alias of aliases) {
|
||||
const from = resolve(alias.from);
|
||||
const to = resolve(alias.to);
|
||||
const remainder = relative(from, resolved);
|
||||
if (remainder === "" || (!remainder.startsWith("..") && remainder !== ".." && !remainder.startsWith(`..${sep}`))) {
|
||||
candidates.push(resolve(to, remainder));
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function normalizePathError(error: unknown, path: string): LibraryPathValidationError {
|
||||
if (error instanceof LibraryPathValidationError) return error;
|
||||
const code = typeof error === "object" && error && "code" in error ? String(error.code) : "";
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return new LibraryPathValidationError("LIBRARY_PATH_NOT_FOUND", path);
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path);
|
||||
}
|
||||
return new LibraryPathValidationError("LIBRARY_PATH_NOT_READABLE", path);
|
||||
}
|
||||
|
||||
function messageForCode(code: LibraryPathErrorCode): string {
|
||||
if (code === "LIBRARY_PATH_NOT_FOUND") return "Library path does not exist";
|
||||
if (code === "LIBRARY_PATH_NOT_DIRECTORY") return "Library path must be a directory";
|
||||
return "Library path is not readable";
|
||||
}
|
||||
Reference in New Issue
Block a user