Files
portal/tools/path-normalization.test.mjs
T
Дмитрий 2a3b5b4da5 fix(router-gate-v4): Smoke 5 REAL fix — path-normalization separator bug
Smoke 5 restart-test (chistaa session) refuted stale-process hypothesis and
identified the real bug: Stream A's pathNormalize() returned OS-native paths
(backslashes on win32) while DEFAULT_PROTECTED_PATTERNS regexes are forward-slash
only.

Trace confirmation:
  Stream A pathNormalize('~/foo/bar.jsonl') on win32:
    BEFORE: 'c:\\users\\admin\\foo\\bar.jsonl' — backslashes
    AFTER:  'c:/users/admin/foo/bar.jsonl'      — forward slashes
  isProtectedPath now matches → Bash/PowerShell hooks block correctly.

Root cause: path.resolve() + fs.realpathSync() on Windows produce backslashes,
caseFold lowercases them but doesn't change separators. DEFAULT_PROTECTED_PATTERNS
in shell-content-rules.mjs are forward-slash regexes (e.g. /(^|\/)\.claude\/projects/i).
defaultPathNormalize fallback in shell-content-rules.mjs DID normalize separators,
which is why my emergency commit 25e184e5 unit-tests passed but live behavior
failed — live hooks use resolvePathNormalize() which returns Stream A's
buggy implementation.

Fix:
- path-normalization.mjs: append .split('\\').join('/') to pathNormalize output.
- path-normalization.test.mjs: +1 RED→GREEN test for win32 separator normalization.

Why previous commit 25e184e5 was incomplete:
- Added pattern to protected list ✓
- Added enforce-read-path-deny.mjs ✓ (Read tool — works because hook uses
  defaultPathNormalize directly, not resolvePathNormalize)
- Did NOT detect Bash/PowerShell path-normalize integration bug (debug script
  bypassed Stream A by passing defaultPathNormalize directly).

Side observation (recorded as Stream H TODO by chistaa session):
- extractPathArgs/pathDenyOverlay — non-reading path in non-first position is
  not checked fully. Independent latent bug, separate fix.

Regression: 1715/1715 vitest tools GREEN (+1 separator test).

Critical: re-run Smoke 5 in clean session — expected PASS all 6 vectors now.
2026-05-30 08:39:52 +03:00

133 lines
5.1 KiB
JavaScript

// tools/path-normalization.test.mjs
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import {
expandHome, expandEnvVars, caseFold, globToRegExp, globMatch, pathNormalize,
} from './path-normalization.mjs';
describe('expandHome', () => {
it('replaces leading ~ with homedir', () => {
expect(expandHome('~/.claude/runtime', '/home/u')).toBe('/home/u/.claude/runtime');
});
it('replaces bare ~', () => {
expect(expandHome('~', '/home/u')).toBe('/home/u');
});
it('does NOT replace ~ in the middle', () => {
expect(expandHome('/a/~/b', '/home/u')).toBe('/a/~/b');
});
it('leaves path without ~ unchanged', () => {
expect(expandHome('/abs/path', '/home/u')).toBe('/abs/path');
});
});
describe('expandEnvVars', () => {
it('%APPDATA%/x → /a/x', () => {
expect(expandEnvVars('%APPDATA%/x', { APPDATA: '/a' })).toBe('/a/x');
});
it('${HOME}/y → /h/y', () => {
expect(expandEnvVars('${HOME}/y', { HOME: '/h' })).toBe('/h/y');
});
it('$USERPROFILE\\z → C:/u\\z', () => {
expect(expandEnvVars('$USERPROFILE\\z', { USERPROFILE: 'C:/u' })).toBe('C:/u\\z');
});
it('%SECRET%/x unchanged (not whitelisted)', () => {
expect(expandEnvVars('%SECRET%/x', { SECRET: '/s' })).toBe('%SECRET%/x');
});
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', () => {
it('lowercases on win32', () => {
expect(caseFold('C:/Foo/Bar', 'win32')).toBe('c:/foo/bar');
});
it('unchanged on linux', () => {
expect(caseFold('/Foo/Bar', 'linux')).toBe('/Foo/Bar');
});
});
describe('globToRegExp / globMatch', () => {
it('single * matches within segment', () => {
expect(globMatch('docs/superpowers/plans/x.md', 'docs/superpowers/plans/*.md')).toBe(true);
});
it('single * does NOT cross segment boundary', () => {
expect(globMatch('docs/superpowers/plans/sub/x.md', 'docs/superpowers/plans/*.md')).toBe(false);
});
it('** matches across segments', () => {
expect(globMatch('a/b/c/x.test.mjs', '**/*.test.mjs')).toBe(true);
});
it('** matches wildcard extension', () => {
expect(globMatch('x.test.ts', '**/*.test.*')).toBe(true);
});
it('does not match unrelated path', () => {
expect(globMatch('app/Models/User.php', 'docs/superpowers/plans/*.md')).toBe(false);
});
it('tests/** matches nested', () => {
expect(globMatch('tests/Feature/Foo.php', 'tests/**')).toBe(true);
});
it('backslash normalized before match', () => {
expect(globMatch('C:\\proj\\tests\\a.test.mjs', '**/*.test.mjs')).toBe(true);
});
});
describe('pathNormalize', () => {
it('expands ~ and resolves', () => {
expect(pathNormalize('~/x', { homedir: '/h', realpath: (p) => p, resolve: (p) => p, platform: 'linux', env: {} })).toBe('/h/x');
});
// Smoke 5 integration bug 2026-05-30 — Stream A pathNormalize returned backslashes
// on win32, breaking DEFAULT_PROTECTED_PATTERNS regex (forward-slash-only) match.
// Fix: normalize all separators to forward slashes in output, regardless of platform.
it('normalizes backslashes to forward slashes on win32 (integration fix for protected patterns)', () => {
const result = pathNormalize('~/foo/bar.jsonl', {
homedir: 'C:\\Users\\Admin',
realpath: (p) => p,
resolve: path.win32.resolve,
platform: 'win32',
env: {},
});
expect(result).not.toMatch(/\\/);
expect(result.includes('/')).toBe(true);
});
it('collapses .. via path.resolve', () => {
// Use path.posix.resolve to simulate POSIX behaviour cross-platform
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 (no .code)', () => {
expect(pathNormalize('/missing', {
realpath: () => { throw new Error('ENOENT'); },
resolve: (p) => p,
platform: 'linux',
homedir: '/h',
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');
});
});