// src/services/compose-cache.ts
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { validateHostname } from "../utils/path-security.js";
import { getCurrentTimestamp } from "../utils/time.js";
import { type CacheStats, type ICacheLayer, LRUCacheLayer } from "./cache-layer.js";
export interface CachedProject {
path: string;
name: string;
discoveredFrom: "docker-ls" | "scan" | "user-provided";
lastSeen: string;
}
export interface CacheData {
lastScan: string;
searchPaths: string[];
projects: Record<string, CachedProject>;
}
const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Zod schemas for runtime validation of JSON cache files
const CachedProjectSchema = z.object({
path: z.string(),
name: z.string(),
discoveredFrom: z.enum(["docker-ls", "scan", "user-provided"]),
lastSeen: z.string(),
});
const CacheDataSchema = z.object({
lastScan: z.string(),
searchPaths: z.array(z.string()),
projects: z.record(z.string(), CachedProjectSchema),
});
/**
* Two-tier cache for Docker Compose project metadata.
* Uses an in-memory LRU cache (L1) backed by file-based storage (L2).
*
* @example
* ```typescript
* const cache = new ComposeProjectCache('.cache/compose-projects', 24*60*60*1000, 50);
* await cache.updateProject('host', 'plex', projectData);
* const project = await cache.getProject('host', 'plex'); // Fast memory lookup
* ```
*/
export class ComposeProjectCache {
private memoryCache: ICacheLayer<string, CachedProject>;
/**
* Create a new ComposeProjectCache with two-tier caching.
*
* @param cacheDir - Directory for file-based cache storage
* @param cacheTtlMs - Time-to-live for cache entries in milliseconds (default: 24 hours)
* @param memoryCacheSize - Maximum number of entries in memory cache (default: 50)
*/
constructor(
private cacheDir = ".cache/compose-projects",
private cacheTtlMs = DEFAULT_CACHE_TTL_MS,
memoryCacheSize = 50
) {
this.memoryCache = new LRUCacheLayer<string, CachedProject>(memoryCacheSize, cacheTtlMs);
}
async load(host: string): Promise<CacheData> {
// SECURITY: Validate host to prevent path traversal attacks (CWE-22)
validateHostname(host);
const file = join(this.cacheDir, `${host}.json`);
try {
const data = await readFile(file, "utf-8");
const parsed = JSON.parse(data);
// Runtime validation: protect against corrupted cache files
return CacheDataSchema.parse(parsed);
} catch (error) {
// Only return empty cache if file doesn't exist
// Re-throw validation errors to catch corrupted cache files
if (error instanceof z.ZodError) {
throw new Error(`Cache file validation failed for ${host}: ${error.message}`, {
cause: error,
});
}
// File not found or JSON parse error - return empty cache
return this.emptyCache();
}
}
async save(host: string, data: CacheData): Promise<void> {
// SECURITY: Validate host to prevent path traversal attacks (CWE-22)
validateHostname(host);
await mkdir(this.cacheDir, { recursive: true });
const file = join(this.cacheDir, `${host}.json`);
const tempFile = join(this.cacheDir, `${host}.json.tmp`);
// Atomic write: write to temp file in same directory, then rename
// Using same directory ensures rename works across all filesystems
await writeFile(tempFile, JSON.stringify(data, null, 2));
await rename(tempFile, file);
}
/**
* Retrieve a project from cache (memory first, then file).
* Returns undefined if project doesn't exist or is stale.
*
* @param host - The host identifier
* @param projectName - The project name
* @returns The cached project or undefined if not found/stale
*/
async getProject(host: string, projectName: string): Promise<CachedProject | undefined> {
// SECURITY: Validate host to prevent path traversal attacks (CWE-22)
validateHostname(host);
const cacheKey = `${host}:${projectName}`;
// L1 Cache: Check memory cache first (fast path)
const memoryHit = this.memoryCache.get(cacheKey);
if (memoryHit) {
// Check TTL - return undefined if stale
if (this.isStale(memoryHit.lastSeen)) {
this.memoryCache.delete(cacheKey);
return undefined;
}
return memoryHit;
}
// L2 Cache: Fall back to file cache
const data = await this.load(host);
const project = data.projects[projectName];
// Check TTL - return undefined if stale
if (project && this.isStale(project.lastSeen)) {
return undefined;
}
// If found in file cache, populate memory cache (cache warming)
if (project) {
this.memoryCache.set(cacheKey, project);
}
return project;
}
/**
* Check if a cache entry is stale based on TTL
*/
private isStale(lastSeenIso: string): boolean {
const lastSeen = new Date(lastSeenIso).getTime();
const now = Date.now();
return now - lastSeen > this.cacheTtlMs;
}
/**
* Update or add a project in both memory and file cache.
*
* @param host - The host identifier
* @param projectName - The project name
* @param project - The project data to cache
*/
async updateProject(host: string, projectName: string, project: CachedProject): Promise<void> {
// SECURITY: Validate host to prevent path traversal attacks (CWE-22)
validateHostname(host);
const cacheKey = `${host}:${projectName}`;
// Update file cache (L2) - persistence layer
const data = await this.load(host);
data.projects[projectName] = project;
data.lastScan = getCurrentTimestamp();
await this.save(host, data);
// Update memory cache (L1) - fast access layer
this.memoryCache.set(cacheKey, project);
}
/**
* Remove a project from both memory and file cache.
*
* @param host - The host identifier
* @param projectName - The project name to remove
*/
async removeProject(host: string, projectName: string): Promise<void> {
// SECURITY: Validate host to prevent path traversal attacks (CWE-22)
validateHostname(host);
const cacheKey = `${host}:${projectName}`;
// Remove from memory cache (L1)
this.memoryCache.delete(cacheKey);
// Remove from file cache (L2)
const data = await this.load(host);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete data.projects[projectName];
await this.save(host, data);
}
/**
* Get memory cache statistics for monitoring and debugging.
* Provides metrics on cache performance and resource usage.
*
* @returns Cache statistics including hits, misses, size, and evictions
*/
getCacheStats(): CacheStats {
return this.memoryCache.getStats();
}
private emptyCache(): CacheData {
return {
lastScan: getCurrentTimestamp(),
searchPaths: [],
projects: {},
};
}
}