From 52e1cfec1a53e57c9d0202743cd761cda679e56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 29 May 2026 19:48:49 +0300 Subject: [PATCH] =?UTF-8?q?fix(router-gate):=20stream=20A=20path-normaliza?= =?UTF-8?q?tion=20=E2=80=94=20$&=20replacement,=20narrow=20catch,=20BOM/EO?= =?UTF-8?q?F,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tools/path-normalization.mjs | 26 ++++++++++++++++++++------ tools/path-normalization.test.mjs | 25 ++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/tools/path-normalization.mjs b/tools/path-normalization.mjs index f5b9d86e..97f8bc23 100644 --- a/tools/path-normalization.mjs +++ b/tools/path-normalization.mjs @@ -1,4 +1,4 @@ -// tools/path-normalization.mjs +// tools/path-normalization.mjs /** * Path normalization — router-gate v4 spec §3.1.1. * + glob-matcher util (used by skill-scope-verifier, tdd-real-test-verifier). @@ -25,8 +25,9 @@ export function expandEnvVars(target, env) { if (val === undefined) continue; out = out.split(`%${name}%`).join(val); out = out.split(`\${${name}}`).join(val); - // bare $VAR — only when followed by non-word boundary - out = out.replace(new RegExp(`\\$${name}(?![A-Za-z0-9_])`, 'g'), val); + // bare $VAR — only when followed by non-word boundary. + // Use a function replacer so `val` is inserted literally (avoids $& / $' / $` replacement-pattern misinterpretation). + out = out.replace(new RegExp(`\\$${name}(?![A-Za-z0-9_])`, 'g'), () => val); } return out; } @@ -35,6 +36,7 @@ export function caseFold(p, platform) { return platform === 'win32' ? p.toLowerCase() : p; } +// NOTE: `pattern` must use forward slashes. For cross-platform path matching use `globMatch` instead. export function globToRegExp(pattern) { let re = ''; for (let i = 0; i < pattern.length; i++) { @@ -63,6 +65,17 @@ export function globMatch(pathStr, pattern) { return globToRegExp(norm(pattern)).test(norm(pathStr)); } +/** + * Normalize a path: expand ~, expand whitelisted env vars, resolve, realpath, case-fold. + * + * @param {string} target - Raw path (may contain ~ or $VAR). + * @param {object} [opts] + * @param {string} [opts.homedir] - Override home directory (default: os.homedir()). + * @param {object} [opts.env] - Override environment map (default: process.env). + * @param {string} [opts.platform] - Override platform string (default: process.platform). + * @param {Function} [opts.realpath] - Injectable realpath (default: fs.realpathSync) — used for test determinism. + * @param {Function} [opts.resolve] - Injectable path.resolve (default: path.resolve) — injectable for cross-platform test determinism. + */ export function pathNormalize(target, opts = {}) { const { homedir = os.homedir(), @@ -77,8 +90,9 @@ export function pathNormalize(target, opts = {}) { let real; try { real = realpath(resolved); - } catch { - real = resolved; // ENOENT — best-effort resolved path + } catch (e) { + if (e && e.code && e.code !== 'ENOENT') throw e; // surface real FS errors; fail-close handled by caller + real = resolved; // ENOENT — best-effort resolved path for unknown-state files } return caseFold(real, platform); -} \ No newline at end of file +} diff --git a/tools/path-normalization.test.mjs b/tools/path-normalization.test.mjs index c0d9c9c4..b6c77d6a 100644 --- a/tools/path-normalization.test.mjs +++ b/tools/path-normalization.test.mjs @@ -36,6 +36,9 @@ describe('expandEnvVars', () => { it('$HOMEDIR not matched inside longer name (boundary)', () => { expect(expandEnvVars('$HOMEDIR', { HOME: '/h' })).toBe('$HOMEDIR'); }); + it('val containing $& is inserted literally (no replacement-pattern misinterpretation)', () => { + expect(expandEnvVars('$HOME/test', { HOME: '/a/$&/x' })).toBe('/a/$&/x/test'); + }); }); describe('caseFold', () => { @@ -80,7 +83,7 @@ describe('pathNormalize', () => { const result = pathNormalize('a/../b', { realpath: (p) => p, resolve: path.posix.resolve, platform: 'linux', homedir: '/h', env: {} }); expect(result.endsWith('/b')).toBe(true); }); - it('falls back to resolved on ENOENT', () => { + it('falls back to resolved on ENOENT (no .code)', () => { expect(pathNormalize('/missing', { realpath: () => { throw new Error('ENOENT'); }, resolve: (p) => p, @@ -89,6 +92,26 @@ describe('pathNormalize', () => { env: {}, })).toBe('/missing'); }); + it('falls back to resolved when error has code ENOENT', () => { + const err = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + expect(pathNormalize('/missing2', { + realpath: () => { throw err; }, + resolve: (p) => p, + platform: 'linux', + homedir: '/h', + env: {}, + })).toBe('/missing2'); + }); + it('rethrows non-ENOENT errors (e.g. EACCES)', () => { + const err = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + expect(() => pathNormalize('/denied', { + realpath: () => { throw err; }, + resolve: (p) => p, + platform: 'linux', + homedir: '/h', + env: {}, + })).toThrow('permission denied'); + }); it('case-folds on win32', () => { expect(pathNormalize('/A/B', { realpath: (p) => p, resolve: (p) => p, platform: 'win32', homedir: '/h', env: {} })).toBe('/a/b'); });