diff --git a/lib/router.js b/lib/router.js index 9a14aca..cbcdff2 100644 --- a/lib/router.js +++ b/lib/router.js @@ -20,14 +20,24 @@ function getBaseName(normalizedPath) { return pathPosix.basename(normalizedPath); } +// normalizeVFSPath uses pathPosix.normalize for /-prefixed paths and +// path.normalize for native paths (which produces \ on Windows). Both +// styles can reach isUnderMountPoint, so we accept either separator. +function isPathSeparator(char) { + return char === '/' || char === '\\'; +} + function isUnderMountPoint(normalizedPath, mountPoint) { if (normalizedPath === mountPoint) { return true; } - if (mountPoint === '/') { - return normalizedPath.startsWith('/'); + if (!normalizedPath.startsWith(mountPoint)) { + return false; } - return normalizedPath.startsWith(mountPoint + '/'); + // The path starts with the mount point — accept it only when the very next + // character is a path separator (e.g. /app → /app/file) or the mount + // point itself already ends with one (e.g. C:\ or /). + return isPathSeparator(normalizedPath[mountPoint.length]) || isPathSeparator(mountPoint[mountPoint.length - 1]); } function getRelativePath(normalizedPath, mountPoint) { diff --git a/test/mount.test.js b/test/mount.test.js index c22700b..c73ff79 100644 --- a/test/mount.test.js +++ b/test/mount.test.js @@ -95,6 +95,76 @@ describe('VirtualFileSystem - mount/unmount', () => { }); }); +describe('VirtualFileSystem - backslash path mount', () => { + let vfs; + + beforeEach(() => { + vfs = create({ moduleHooks: false }); + }); + + afterEach(() => { + if (vfs.mounted) { + vfs.unmount(); + } + }); + + it('shouldHandle accepts backslash paths under mount point', () => { + vfs.mount('C:\\app'); + assert.strictEqual(vfs.shouldHandle('C:\\app\\file.txt'), true); + assert.strictEqual(vfs.shouldHandle('C:\\app'), true); + }); + + it('shouldHandle rejects backslash paths outside mount point', () => { + vfs.mount('C:\\app'); + assert.strictEqual(vfs.shouldHandle('C:\\other\\file.txt'), false); + }); + + it('shouldHandle rejects backslash paths that are a prefix but not a child', () => { + vfs.mount('C:\\app'); + assert.strictEqual(vfs.shouldHandle('C:\\application\\file.txt'), false); + }); + + it('shouldHandle works with a drive root mount', () => { + vfs.writeFileSync('/data.json', '{}'); + vfs.mount('C:\\'); + assert.strictEqual(vfs.shouldHandle('C:\\data.json'), true); + assert.strictEqual(vfs.shouldHandle('C:\\deep\\nested\\path'), true); + }); + + it('mountPoint preserves backslashes', () => { + vfs.mount('C:\\app'); + assert.strictEqual(vfs.mountPoint, 'C:\\app'); + }); +}); + +describe('VirtualFileSystem - Windows path I/O', { skip: process.platform !== 'win32' }, () => { + let vfs; + + beforeEach(() => { + vfs = create({ moduleHooks: false }); + }); + + afterEach(() => { + if (vfs.mounted) { + vfs.unmount(); + } + }); + + it('readFileSync works through a Windows mount point', () => { + vfs.writeFileSync('/file.txt', 'windows mount content'); + vfs.mount('C:\\mnt'); + const content = vfs.readFileSync('C:\\mnt\\file.txt', 'utf8'); + assert.strictEqual(content, 'windows mount content'); + }); + + it('throws ENOENT for paths outside Windows mount point', () => { + vfs.mount('C:\\app'); + assert.throws(() => vfs.readFileSync('C:\\outside\\file.txt'), { + code: 'ENOENT', + }); + }); +}); + describe('VirtualFileSystem - overlay mode', () => { let vfs; diff --git a/test/router.test.js b/test/router.test.js new file mode 100644 index 0000000..31a9995 --- /dev/null +++ b/test/router.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { + isUnderMountPoint, + getRelativePath, + splitPath, + getParentPath, + getBaseName, +} = require('../lib/router.js'); + +describe('isUnderMountPoint', () => { + it('returns true when path equals mount point', () => { + assert.strictEqual(isUnderMountPoint('/app', '/app'), true); + }); + + it('returns true for path under posix mount point', () => { + assert.strictEqual(isUnderMountPoint('/app/file.txt', '/app'), true); + }); + + it('returns false for path not under mount point', () => { + assert.strictEqual(isUnderMountPoint('/other/file.txt', '/app'), false); + }); + + it('returns false for path that is a prefix but not a child', () => { + assert.strictEqual(isUnderMountPoint('/application/file.txt', '/app'), false); + }); + + it('returns true for any path when mount point is /', () => { + assert.strictEqual(isUnderMountPoint('/file.txt', '/'), true); + assert.strictEqual(isUnderMountPoint('/deep/nested/path', '/'), true); + }); + + // Windows-style paths (backslash separators) + + it('returns true when Windows path equals mount point', () => { + assert.strictEqual(isUnderMountPoint('C:\\foo\\mount', 'C:\\foo\\mount'), true); + }); + + it('returns true for Windows path under mount point', () => { + assert.strictEqual(isUnderMountPoint('C:\\foo\\mount\\file.js', 'C:\\foo\\mount'), true); + }); + + it('returns false for Windows path not under mount point', () => { + assert.strictEqual(isUnderMountPoint('C:\\bar\\file.js', 'C:\\foo\\mount'), false); + }); + + it('returns false for Windows path that is a prefix but not a child', () => { + assert.strictEqual(isUnderMountPoint('C:\\foo\\mountextra\\file.js', 'C:\\foo\\mount'), false); + }); + + it('returns true for paths under a Windows drive root mount', () => { + assert.strictEqual(isUnderMountPoint('C:\\file.txt', 'C:\\'), true); + assert.strictEqual(isUnderMountPoint('C:\\deep\\nested\\path', 'C:\\'), true); + }); + + it('returns true for nested path under Windows mount point', () => { + assert.strictEqual(isUnderMountPoint('C:\\foo\\mount\\sub\\dir\\file.js', 'C:\\foo\\mount'), true); + }); +}); + +describe('getRelativePath', () => { + it('returns / when path equals mount point', () => { + assert.strictEqual(getRelativePath('/app', '/app'), '/'); + }); + + it('returns full path when mount point is /', () => { + assert.strictEqual(getRelativePath('/file.txt', '/'), '/file.txt'); + }); + + it('strips the mount point prefix', () => { + assert.strictEqual(getRelativePath('/app/file.txt', '/app'), '/file.txt'); + }); +}); + +describe('splitPath', () => { + it('returns empty array for root', () => { + assert.deepStrictEqual(splitPath('/'), []); + }); + + it('splits a path into segments', () => { + assert.deepStrictEqual(splitPath('/a/b/c'), ['a', 'b', 'c']); + }); +}); + +describe('getParentPath', () => { + it('returns null for root', () => { + assert.strictEqual(getParentPath('/'), null); + }); + + it('returns parent directory', () => { + assert.strictEqual(getParentPath('/a/b'), '/a'); + }); +}); + +describe('getBaseName', () => { + it('returns base name', () => { + assert.strictEqual(getBaseName('/a/b/c.txt'), 'c.txt'); + }); +});