397777089e
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
189 lines
7.9 KiB
JavaScript
189 lines
7.9 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, pathNormalizeSafe,
|
||
} from './path-normalization.mjs';
|
||
|
||
describe('pathNormalizeSafe тотальна (M7 Фаза 0, правило 7а, SE-I/L6)', () => {
|
||
it('не бросает когда realpath кидает не-ENOENT (EACCES) → ""', () => {
|
||
const boom = () => { const e = new Error('denied'); e.code = 'EACCES'; throw e; };
|
||
const opts = { realpath: boom, homedir: '/h', platform: 'linux', resolve: (p) => p };
|
||
expect(() => pathNormalizeSafe('/x', opts)).not.toThrow();
|
||
expect(pathNormalizeSafe('/x', opts)).toBe('');
|
||
});
|
||
it('успех → тот же результат, что строгий pathNormalize', () => {
|
||
const id = (p) => p;
|
||
const opts = { realpath: id, homedir: '/h', platform: 'linux', resolve: id };
|
||
expect(pathNormalizeSafe('/a/b', opts)).toBe('/a/b');
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
// Stream H Task 9 cosmetic: /c/Users/... (Cygwin/git-bash) → c:/Users/...
|
||
// Without this, win32 path.resolve('/c/Users/x') prepends cwd's drive letter
|
||
// → c:/c/users/... (doubled drive). Detected during Smoke 5 Real Fix Re-test
|
||
// 2026-05-30 (step 4 path display).
|
||
it('normalizes /c/Users (cygwin/git-bash drive prefix) to c:/Users before resolve (win32)', () => {
|
||
const r = pathNormalize('/c/Users/Admin/.claude/projects/x.jsonl', {
|
||
homedir: 'C:\\Users\\Admin',
|
||
realpath: (p) => p,
|
||
resolve: path.win32.resolve,
|
||
platform: 'win32',
|
||
env: {},
|
||
});
|
||
// No double c:/c/, has single c:/users
|
||
expect(r).not.toMatch(/c:\/c\//);
|
||
expect(r).toMatch(/c:\/users\/admin/);
|
||
});
|
||
});
|
||
|
||
describe('expandEnvVars — PowerShell $env:VAR (Stream H Task 9 cosmetic)', () => {
|
||
it('expands $env:USERPROFILE to actual home path', () => {
|
||
expect(expandEnvVars('$env:USERPROFILE/.claude/projects/x.jsonl', { USERPROFILE: 'C:/Users/Admin' }))
|
||
.toBe('C:/Users/Admin/.claude/projects/x.jsonl');
|
||
});
|
||
it('expands $env:HOME (whitelisted) too', () => {
|
||
expect(expandEnvVars('$env:HOME/x', { HOME: '/h' })).toBe('/h/x');
|
||
});
|
||
it('does not expand non-whitelisted $env:SECRET', () => {
|
||
expect(expandEnvVars('$env:SECRET/x', { SECRET: '/s' })).toBe('$env:SECRET/x');
|
||
});
|
||
});
|
||
|
||
// P10-b (router-mentor Машина 1): NFC-нормализация выхода — декомпозированный
|
||
// Unicode (NFD) не должен обходить regex-защиты, сверяющиеся с композированной формой.
|
||
describe('pathNormalize NFC (P10-b)', () => {
|
||
it('NFC-normalizes output so decomposed input equals composed', () => {
|
||
const opts = { platform: 'linux', homedir: '/home/u', realpath: (p) => p, resolve: (p) => p, env: {} };
|
||
const composed = '/tmp/café/x'; // é precomposed (NFC)
|
||
const decomposed = '/tmp/café/x'; // e + combining acute (NFD)
|
||
expect(composed).not.toBe(decomposed); // guard: genuinely different bytes
|
||
expect(pathNormalize(decomposed, opts)).toBe(pathNormalize(composed, opts));
|
||
});
|
||
});
|