import { readFileSync, statSync } from 'fs';
import { resolve, relative, isAbsolute } from 'path';
import {
FilePlaceholderConfig,
FilePlaceholderError,
FilePlaceholderErrorCode,
FilePlaceholderResolutionResult,
DEFAULT_FILE_PLACEHOLDER_CONFIG,
FILE_PLACEHOLDER_ERROR_CODES,
FILE_PLACEHOLDER_ENV_VARS,
} from './filePlaceholderTypes.js';
/**
* File Placeholder Resolver
*
* Resolves {{file:...}} placeholders in objects by reading file contents.
* Supports multiple placeholders in single strings and recursive object traversal.
*/
/**
* Load configuration from environment variables
*/
function loadFilePlaceholderConfig(): Required<FilePlaceholderConfig> {
const maxFileSize = process.env[FILE_PLACEHOLDER_ENV_VARS.MAX_FILE_SIZE];
return {
maxFileSize: maxFileSize ? parseInt(maxFileSize, 10) : DEFAULT_FILE_PLACEHOLDER_CONFIG.maxFileSize,
};
}
/**
* Validate file path - requires absolute paths only
*/
function validateFilePath(filePath: string): void {
// Require absolute paths only
if (!isAbsolute(filePath)) {
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_INVALID_PATH,
`File path must be absolute (start with /). Got: ${filePath}. Use full path like /home/user/file.txt`,
`{{file:${filePath}}}`,
filePath
);
}
// Check for directory traversal attempts
if (filePath.includes('..')) {
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_INVALID_PATH,
'File path contains directory traversal (..) which is not allowed',
`{{file:${filePath}}}`,
filePath
);
}
}
/**
* Read file content with size validation
*/
function readFileContent(filePath: string, maxFileSize: number): string {
try {
// Validate path (requires absolute path)
validateFilePath(filePath);
// Check if file exists and get size
const stats = statSync(filePath);
if (!stats.isFile()) {
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_NOT_FOUND,
'Path exists but is not a file',
`{{file:${filePath}}}`,
filePath
);
}
// Check file size
if (stats.size > maxFileSize) {
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_SIZE_EXCEEDED,
`File size (${stats.size} bytes) exceeds maximum allowed size (${maxFileSize} bytes)`,
`{{file:${filePath}}}`,
filePath
);
}
// Read file content
const content = readFileSync(filePath, 'utf-8');
return content;
} catch (error) {
if (error instanceof FilePlaceholderError) {
throw error;
}
// Handle file not found
if ((error as any)?.code === 'ENOENT') {
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_NOT_FOUND,
'File not found',
`{{file:${filePath}}}`,
filePath,
error as Error
);
}
// Handle other read errors
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_READ_ERROR,
`Failed to read file: ${(error as Error).message}`,
`{{file:${filePath}}}`,
filePath,
error as Error
);
}
}
/**
* Resolve file placeholders in a string
*/
function resolveStringPlaceholders(
input: string,
maxFileSize: number,
result: FilePlaceholderResolutionResult
): string {
// Regex to match {{file:...}} patterns
const placeholderRegex = /\{\{file:([^}]+)\}\}/g;
let output = input;
let match;
while ((match = placeholderRegex.exec(input)) !== null) {
const placeholder = match[0];
const filePath = match[1].trim();
try {
const fileContent = readFileContent(filePath, maxFileSize);
// Replace the placeholder with file content
output = output.replace(placeholder, fileContent);
// Update result tracking
result.resolved = true;
result.placeholderCount++;
result.totalResolvedSize += fileContent.length;
result.resolvedFiles.push(filePath);
} catch (error) {
if (error instanceof FilePlaceholderError) {
throw error;
}
// Convert unknown errors to FilePlaceholderError
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_ERROR,
`Unexpected error resolving placeholder: ${(error as Error).message}`,
placeholder,
filePath,
error as Error
);
}
}
return output;
}
/**
* Recursively resolve file placeholders in any data structure
*/
function resolveDataStructurePlaceholders(
input: any,
maxFileSize: number,
result: FilePlaceholderResolutionResult
): any {
if (typeof input === 'string') {
return resolveStringPlaceholders(input, maxFileSize, result);
}
if (Array.isArray(input)) {
return input.map(item => resolveDataStructurePlaceholders(item, maxFileSize, result));
}
if (input && typeof input === 'object') {
const resolved: any = {};
for (const [key, value] of Object.entries(input)) {
resolved[key] = resolveDataStructurePlaceholders(value, maxFileSize, result);
}
return resolved;
}
return input;
}
/**
* Main function to resolve file placeholders in any data structure
*
* @param input - The data structure to process (can be any type)
* @param config - Optional configuration (uses environment defaults if not provided)
* @returns Object with resolved data and resolution metadata
*/
export function resolveFilePlaceholders(
input: any,
config?: Partial<FilePlaceholderConfig>
): { data: any; result: FilePlaceholderResolutionResult } {
const finalConfig = { ...DEFAULT_FILE_PLACEHOLDER_CONFIG, ...loadFilePlaceholderConfig(), ...config };
const result: FilePlaceholderResolutionResult = {
resolved: false,
placeholderCount: 0,
totalResolvedSize: 0,
resolvedFiles: [],
};
try {
const resolvedData = resolveDataStructurePlaceholders(
input,
finalConfig.maxFileSize,
result
);
return { data: resolvedData, result };
} catch (error) {
if (error instanceof FilePlaceholderError) {
throw error;
}
// Convert unknown errors
throw new FilePlaceholderError(
FILE_PLACEHOLDER_ERROR_CODES.FILE_PLACEHOLDER_ERROR,
`Failed to resolve file placeholders: ${(error as Error).message}`,
'unknown',
'unknown',
error as Error
);
}
}
/**
* Check if a string contains file placeholders without resolving them
*/
export function hasFilePlaceholders(input: string): boolean {
return /\{\{file:[^}]+\}\}/.test(input);
}
/**
* Get all file placeholders in a string
*/
export function extractFilePlaceholders(input: string): string[] {
const matches = input.match(/\{\{file:([^}]+)\}\}/g);
return matches ? matches.map(match => match.replace(/\{\{file:|\}\}/g, '').trim()) : [];
}