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:
Git Agent
2026-08-23 16:23:57 +02:00
parent 0fa2f99289
commit 7b72cc0d83
8 changed files with 194 additions and 16 deletions

View 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";
}