From ddfc51b7c5add001fe00222a500f787dae40fc4c Mon Sep 17 00:00:00 2001 From: Frotty Date: Mon, 7 Sep 2026 10:46:11 +0200 Subject: [PATCH] Namespace the extracted game-asset cache by installation root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cache under ~/.wurst/casc_cache/{casc|mpq}/... was keyed only by storage kind, so pointing wurst.wc3path at a different install of the same kind (two Reforged builds, a PTR beside retail) kept serving assets extracted from whichever one was cached first. #109 made a root switch take effect at runtime, which made it easy to hit. Each cache path is now keyed by kind + a short hash of the install root, and the lookups hand back the exact path they read or wrote rather than re-deriving it afterwards from a root that may have changed in between. The tag hashes the root's realpath, so the same install reached via a symlink, a different drive-letter case or a hand-typed setting shares one bucket instead of duplicating the whole extracted tree; the path given to the storage openers is unchanged. Three routes that bypassed the namespace are closed: the pre-kind-split casc_cache/ layout is no longer read, getCandidateRoots() no longer offers the cache as a generic local asset root, and no path inside the cache can become one — previewing a file that itself lives in the cache used to walk ancestors back into another install's bucket. Cache entries can only be read back when an install is detected, since otherwise there is no way to know which bucket belongs to the active one. Existing caches are orphaned and re-extracted once, the trade-off the issue called for. Closes #110. --- src/features/imageAssetSupport.ts | 22 +++-- src/features/preview/cascStorage.ts | 139 ++++++++++++++++++---------- 2 files changed, 104 insertions(+), 57 deletions(-) diff --git a/src/features/imageAssetSupport.ts b/src/features/imageAssetSupport.ts index 8459bee..9b17b5b 100644 --- a/src/features/imageAssetSupport.ts +++ b/src/features/imageAssetSupport.ts @@ -204,11 +204,24 @@ export async function getCandidateRoots(documentFsPath: string, options: Candida return [...await promise]; } +/** Is `candidate` the extracted game-asset cache directory, or anything beneath it? */ +function isInsideGameAssetCache(candidate: string): boolean { + const forCompare = (p: string) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p)); + const rel = path.relative(forCompare(getGameAssetCacheDir()), forCompare(candidate)); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + async function getCandidateRootsUncached(documentFsPath: string, options: CandidateRootOptions): Promise { const seen = new Set(); const roots: string[] = []; const add = (candidate: string) => { - if (candidate && !seen.has(candidate)) { + // Never let generic local resolution reach into the extracted game-asset cache. Its entries + // are namespaced by installation root and must only be read back through the game-data + // lookups that know which namespace is active — joining an asset path onto the cache dir (or + // onto one installation's bucket) would serve another installation's bytes. This guards every + // source of roots at once, including a document that itself lives inside the cache and the + // ancestor walk below. + if (candidate && !seen.has(candidate) && !isInsideGameAssetCache(candidate)) { seen.add(candidate); roots.push(candidate); } @@ -263,7 +276,6 @@ async function getCandidateRootsUncached(documentFsPath: string, options: Candid } })); - add(getGameAssetCacheDir()); return roots; } @@ -277,12 +289,10 @@ const IMPORT_SKIP_DIRS = new Set(['node_modules', '.git', '.svn', 'dist', 'out', * root (map-relative, WC3 style) so they resolve and serialize correctly. Bounded to keep it cheap. */ export async function gatherImportedAssets(documentFsPath: string): Promise<{ model: ImportedAsset[]; icon: ImportedAsset[]; sound: ImportedAsset[] }> { - const cacheDir = getGameAssetCacheDir(); // Prefer the most specific root. A file under `imports\btn` is reachable both from the workspace // root (`imports\btn\x.blp`) and the dedicated imports root (`btn\x.blp`); the latter is the useful // WC3 asset path. Walking child roots first also lets the physical-path guard below keep that form. const roots = (await getCandidateRoots(documentFsPath)) - .filter((r) => r !== cacheDir) .sort((a, b) => path.resolve(b).split(path.sep).length - path.resolve(a).split(path.sep).length); const model: ImportedAsset[] = []; const icon: ImportedAsset[] = []; @@ -515,10 +525,8 @@ async function resolveCachedGameAsset(variant: string): Promise { const variants = assetPathVariants(assetPath, kind); - const cacheDir = getGameAssetCacheDir(); - const localRoots = roots.filter((root) => root !== cacheDir); for (const variant of variants) { - const resolved = await resolveAssetPath(variant, localRoots, kind); + const resolved = await resolveAssetPath(variant, roots, kind); if (resolved) return resolved; } for (const variant of variants) { diff --git a/src/features/preview/cascStorage.ts b/src/features/preview/cascStorage.ts index 2d2d9e9..68afff7 100644 --- a/src/features/preview/cascStorage.ts +++ b/src/features/preview/cascStorage.ts @@ -1,6 +1,7 @@ 'use strict'; import * as child_process from 'child_process'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -21,7 +22,10 @@ type GameStorageKind = 'casc' | 'mpq'; interface GameDataRoot { kind: GameStorageKind; + /** The installation directory as detected, and what the storage openers are given. */ root: string; + /** `root` resolved through `realpath` — the spelling the disk cache is namespaced by. */ + canonicalRoot: string; } interface GameStorage { @@ -304,6 +308,26 @@ async function computeDefaultWarcraftPaths(): Promise { return candidates; } +/** + * Builds the detection result, pairing the root as discovered with the canonical spelling used to + * key its cache bucket. The same install arrives here spelled several ways — a hand-typed + * `wurst.wc3path`, an uppercase drive letter from the default-path scan, a registry value, a symlink + * — and hashing the spelling verbatim would give each one its own bucket, re-extracting the same + * install into another full copy on disk. `realpath` settles them on the filesystem's own canonical + * form without guessing at case sensitivity, falling back to the path as given if it can't be + * canonicalised. Only the cache tag uses it: `root` stays exactly as detected, since that is what + * gets handed to the native storage openers. + */ +async function makeGameDataRoot(kind: GameStorageKind, root: string): Promise { + let canonicalRoot = root; + try { + canonicalRoot = await fs.promises.realpath(root); + } catch { + // Keep the discovered spelling; a bucket keyed on it is still correct, just not deduplicated. + } + return { kind, root, canonicalRoot }; +} + /** Walk up from `startPath` until we find a WC3 CASC root (has Data/ AND .build.info or .build.db). */ async function findCascDataRoot(startPath: string): Promise { let dir = startPath; @@ -378,25 +402,35 @@ function getCachedAssetPath(cacheDir: string, normalizedAssetPath: string): stri return path.join(cacheDir, ...normalizedAssetPath.replace(/:/g, '$').split('\\')); } -function getSourceCachePath(kind: GameStorageKind, normalizedAssetPath: string): string { - return getCachedAssetPath(path.join(getCacheDir(), kind), normalizedAssetPath); +/** + * Short, filesystem-safe tag distinguishing this game-data root from any other of the same storage + * kind, so switching `wurst.wc3path` between two installs of the same kind (two Reforged builds, or + * a PTR next to retail) can't serve assets that were extracted from the other one. The path is hashed + * exactly as resolved, with no case folding: two spellings that differ only in case are separate + * directories on a case-sensitive filesystem and must never share a bucket. On a case-insensitive one + * they can at worst produce two buckets for the same install, which costs a re-extraction — the safe + * direction, and not worth a filesystem-behaviour probe to avoid. + */ +function installRootTag(root: string): string { + return crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 8); +} + +function getSourceCachePath(root: GameDataRoot, normalizedAssetPath: string): string { + return getCachedAssetPath(path.join(getCacheDir(), root.kind, installRootTag(root.canonicalRoot)), normalizedAssetPath); } export async function findCachedGameAsset(assetPath: string): Promise { const normalized = normalizeCascAssetPath(assetPath); const root = await getGameDataRoot(defaultCascLog); - const candidates = [ - root?.kind ? getSourceCachePath(root.kind, normalized) : '', - // Preserve assets extracted by older extension versions when CASC is active. - root?.kind === 'casc' ? getCachedAssetPath(getCacheDir(), normalized) : '', - ].filter(Boolean); - for (const candidate of candidates) { - try { - await fs.promises.access(candidate, fs.constants.F_OK); - return candidate; - } catch {} + // No live root, no namespace to check — see the comment on readCachedGameBuffer. + if (!root) return undefined; + const candidate = getSourceCachePath(root, normalized); + try { + await fs.promises.access(candidate, fs.constants.F_OK); + return candidate; + } catch { + return undefined; } - return undefined; } function rememberMiss(cache: Set, key: string): void { @@ -458,12 +492,12 @@ async function detectGameDataRoot(log: (msg: string) => void): Promise void): Promise void): Promise<{ buf: Buffer; ext: TextureExt } | null> { - const cacheDir = getCacheDir(); +export async function findCascTexture(texPath: string, log: (msg: string) => void): Promise<{ buf: Buffer; ext: TextureExt; cachePath: string } | null> { const gameRoot = await getGameDataRoot(log); - const cacheKind = gameRoot?.kind ?? 'casc'; // CASC paths are lowercase with backslash separators const basePath = textureBasePath(texPath); const ddsPath = `${basePath}.dds`; @@ -652,13 +684,14 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi if (fallbackDdsPath) cacheCandidates.push([fallbackDdsPath, 'dds']); if (fallbackBlpPath) cacheCandidates.push([fallbackBlpPath, 'blp']); if (fallbackTgaPath) cacheCandidates.push([fallbackTgaPath, 'tga']); - for (const [rel, ext] of cacheCandidates) { - const cachePaths = [getSourceCachePath(cacheKind, rel)]; - if (cacheKind === 'casc') cachePaths.push(getCachedAssetPath(cacheDir, rel)); - for (const cachePath of cachePaths) { + // Disk-cache paths are namespaced by install root, so a lookup needs a live root to know which + // bucket to check — without one we can't tell which install a stale cache entry came from. + if (gameRoot) { + for (const [rel, ext] of cacheCandidates) { + const cachePath = getSourceCachePath(gameRoot, rel); try { const buf = await fs.promises.readFile(cachePath); - return { buf, ext }; + return { buf, ext, cachePath }; } catch {} } } @@ -687,13 +720,13 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi for (const [gamePath, ext] of candidates) { const rel = pathForExt(ext); - const cachePath = getSourceCachePath(gameRoot!.kind, rel); + const cachePath = getSourceCachePath(gameRoot!, rel); const buf = await gameReadDirect(gameRoot!, gamePath, log); if (buf) { log(`${gameRoot!.kind.toUpperCase()} extracted: ${gamePath} (${buf.length} bytes) -> ${cachePath}`); await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }); await fs.promises.writeFile(cachePath, buf); - return { buf, ext }; + return { buf, ext, cachePath }; } } @@ -707,11 +740,11 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi const buf = await gameReadDirect(gameRoot!, found, log); if (!buf) continue; const rel = pathForExt(ext); - const cachePath = getSourceCachePath(gameRoot!.kind, rel); + const cachePath = getSourceCachePath(gameRoot!, rel); log(`${gameRoot!.kind.toUpperCase()} basename-resolved texture: ${baseNoExt}.${ext} -> ${found} (${buf.length} bytes)`); await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }); await fs.promises.writeFile(cachePath, buf); - return { buf, ext }; + return { buf, ext, cachePath }; } } rememberMiss(cascTextureMissCache, missKey); @@ -721,16 +754,17 @@ export async function findCascTexture(texPath: string, log: (msg: string) => voi export const findGameTexture = findCascTexture; -async function readCachedGameBuffer(assetPath: string, root: GameDataRoot | null): Promise { +async function readCachedGameBuffer(assetPath: string, root: GameDataRoot | null): Promise<{ buf: Buffer; cachePath: string } | null> { + // Namespaced cache paths need a known install root — without a live one we can't tell whose + // cache we would be reading, so there is nothing safe to check. + if (!root) return null; const normalized = normalizeCascAssetPath(assetPath); - const cachePaths = [getSourceCachePath(root?.kind ?? 'casc', normalized)]; - if (root?.kind === 'casc') cachePaths.push(getCachedAssetPath(getCacheDir(), normalized)); - for (const cachePath of cachePaths) { - try { - return await fs.promises.readFile(cachePath); - } catch {} + const cachePath = getSourceCachePath(root, normalized); + try { + return { buf: await fs.promises.readFile(cachePath), cachePath }; + } catch { + return null; } - return null; } function gameAssetCandidates(root: GameDataRoot, normalized: string): string[] { @@ -762,13 +796,19 @@ function gameAssetCandidates(root: GameDataRoot, normalized: string): string[] { } async function writeGameCache(root: GameDataRoot, assetPath: string, data: Buffer): Promise { - const cachePath = getSourceCachePath(root.kind, assetPath); + const cachePath = getSourceCachePath(root, assetPath); await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }); await fs.promises.writeFile(cachePath, data); return cachePath; } -export async function findCascAsset(assetPath: string, log: (msg: string) => void): Promise { +/** + * Returns the cache path alongside the buffer so callers that need the on-disk location (e.g. + * `ensureCascAssetCached`) use exactly the path this lookup actually read from or wrote to, instead + * of re-deriving it from the live game-data root afterwards — which can have moved on if + * `wurst.wc3path` changed in between. + */ +async function findCascAssetWithPath(assetPath: string, log: (msg: string) => void): Promise<{ buf: Buffer; cachePath: string } | null> { const normalized = normalizeCascAssetPath(assetPath); const gameRoot = await getGameDataRoot(log); const cached = await readCachedGameBuffer(normalized, gameRoot); @@ -786,7 +826,7 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi if (buf) { const cachePath = await writeGameCache(gameRoot, normalized, buf); log(`${gameRoot.kind.toUpperCase()} extracted: ${gamePath} (${buf.length} bytes) -> ${cachePath}`); - return buf; + return { buf, cachePath }; } } @@ -800,8 +840,8 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi const buf = await gameReadDirect(gameRoot, found, log); if (buf) { log(`${gameRoot.kind.toUpperCase()} basename-resolved: ${basename} -> ${found} (${buf.length} bytes)`); - await writeGameCache(gameRoot, normalized, buf); - return buf; + const cachePath = await writeGameCache(gameRoot, normalized, buf); + return { buf, cachePath }; } } } @@ -811,6 +851,10 @@ export async function findCascAsset(assetPath: string, log: (msg: string) => voi return null; } +export async function findCascAsset(assetPath: string, log: (msg: string) => void): Promise { + return (await findCascAssetWithPath(assetPath, log))?.buf ?? null; +} + export const findGameAsset = findCascAsset; export async function listGameAssetPaths( @@ -872,19 +916,14 @@ export function logGameData(message: string): void { */ export async function ensureCascCached(assetPath: string): Promise { const result = await findCascTexture(assetPath, defaultCascLog); - if (!result) return undefined; - const rel = `${textureBasePath(assetPath)}.${result.ext}`; - const kind = (await getGameDataRoot(defaultCascLog))?.kind ?? 'casc'; - return getSourceCachePath(kind, rel); + return result?.cachePath; } export const ensureGameTextureCached = ensureCascCached; export async function ensureCascAssetCached(assetPath: string): Promise { - const result = await findCascAsset(assetPath, defaultCascLog); - if (!result) return undefined; - const kind = (await getGameDataRoot(defaultCascLog))?.kind ?? 'casc'; - return getSourceCachePath(kind, normalizeCascAssetPath(assetPath)); + const result = await findCascAssetWithPath(assetPath, defaultCascLog); + return result?.cachePath; } export const ensureGameAssetCached = ensureCascAssetCached;