diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts index 43ef6bd..1d881c1 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -32,6 +32,7 @@ function createPlan(repoRoot: string, homeDir: string, items: SyncItem[]): SyncP repoRoot, homeDir, platform: 'linux', + configRoot: path.join(homeDir, '.config', 'opencode'), }; } @@ -330,12 +331,12 @@ describe('relative extra paths', () => { }; expect(configManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([ - [configFile, 'file'], - [configDirectory, 'dir'], + ['SOUL.md', 'file'], + ['custom-configs', 'dir'], ]); expect(secretManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([ - [secretFile, 'file'], - [secretDirectory, 'dir'], + ['credentials/token.json', 'file'], + ['private-agents', 'dir'], ]); const configRepoPaths = new Map( @@ -350,17 +351,17 @@ describe('relative extra paths', () => { path.join(repoRoot, entry.repoPath), ]) ); - await expect(fs.readFile(configRepoPaths.get(configFile) ?? '', 'utf8')).resolves.toBe( + await expect(fs.readFile(configRepoPaths.get('SOUL.md') ?? '', 'utf8')).resolves.toBe( 'config-file' ); await expect( - fs.readFile(path.join(configRepoPaths.get(configDirectory) ?? '', 'custom.md'), 'utf8') + fs.readFile(path.join(configRepoPaths.get('custom-configs') ?? '', 'custom.md'), 'utf8') ).resolves.toBe('config-directory'); - await expect(fs.readFile(secretRepoPaths.get(secretFile) ?? '', 'utf8')).resolves.toBe( - 'secret-file' - ); await expect( - fs.readFile(path.join(secretRepoPaths.get(secretDirectory) ?? '', 'private.md'), 'utf8') + fs.readFile(secretRepoPaths.get('credentials/token.json') ?? '', 'utf8') + ).resolves.toBe('secret-file'); + await expect( + fs.readFile(path.join(secretRepoPaths.get('private-agents') ?? '', 'private.md'), 'utf8') ).resolves.toBe('secret-directory'); } finally { process.chdir(originalCwd); @@ -678,6 +679,136 @@ describe('syncing plural OpenCode config directories', () => { }); }); +describe('portable extra path manifests', () => { + it('moves an extra config file between different homes with canonical manifest paths', async () => { + await withTempDir(async (root) => { + const machineAHome = path.join(root, 'machine-a'); + const machineBHome = path.join(root, 'machine-b'); + const repoRoot = path.join(root, 'repo'); + const machineALocations = resolveSyncLocations({ HOME: machineAHome }, 'linux'); + const machineBLocations = resolveSyncLocations({ HOME: machineBHome }, 'linux'); + const relativeExtraPath = 'custom/nested.json'; + const machineAExtraPath = path.join(machineALocations.configRoot, relativeExtraPath); + const machineBExtraPath = path.join(machineBLocations.configRoot, relativeExtraPath); + await fs.mkdir(path.dirname(machineAExtraPath), { recursive: true }); + await fs.writeFile(machineAExtraPath, 'portable-extra', 'utf8'); + + const commonConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + includeOpencodeSkills: false, + includeAgentsDir: false, + includeModelFavorites: false, + }; + const machineAPlan = buildSyncPlan( + normalizeSyncConfig({ ...commonConfig, extraConfigPaths: [machineAExtraPath] }), + machineALocations, + repoRoot, + 'linux' + ); + const machineBPlan = buildSyncPlan( + normalizeSyncConfig({ ...commonConfig, extraConfigPaths: [machineBExtraPath] }), + machineBLocations, + repoRoot, + 'linux' + ); + + await syncLocalToRepo(machineAPlan, null); + const manifest = JSON.parse( + await fs.readFile(machineAPlan.extraConfigs.manifestPath, 'utf8') + ) as { entries: Array<{ sourcePath: string; repoPath: string }> }; + + expect(manifest.entries).toHaveLength(1); + expect(manifest.entries[0]?.sourcePath).toBe('custom/nested.json'); + expect(manifest.entries[0]?.repoPath).toMatch(/^config\/extra\//); + expect(manifest.entries[0]?.repoPath).not.toContain('\\'); + + await syncRepoToLocal(machineBPlan, null); + await expect(fs.readFile(machineBExtraPath, 'utf8')).resolves.toBe('portable-extra'); + }); + }); + + it('reads legacy Windows separators in a manifest on POSIX', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const localPath = path.join(locations.configRoot, 'custom.json'); + const repoPath = path.join(repoRoot, 'config', 'extra', 'payload'); + await fs.mkdir(path.dirname(repoPath), { recursive: true }); + await fs.writeFile(repoPath, 'legacy-windows-manifest', 'utf8'); + + const plan = buildSyncPlan( + normalizeSyncConfig({ + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: [localPath], + }), + locations, + repoRoot, + 'linux' + ); + await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true }); + await fs.writeFile( + plan.extraConfigs.manifestPath, + JSON.stringify({ + entries: [ + { + sourcePath: 'custom.json', + repoPath: 'config\\extra\\payload', + type: 'file', + }, + ], + }), + 'utf8' + ); + + await syncRepoToLocal(plan, null); + await expect(fs.readFile(localPath, 'utf8')).resolves.toBe('legacy-windows-manifest'); + }); + }); + + it('ignores manifest repository paths outside the sync repo', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const localPath = path.join(locations.configRoot, 'custom.json'); + const outsidePath = path.join(root, 'outside-payload'); + await fs.mkdir(repoRoot, { recursive: true }); + await fs.writeFile(outsidePath, 'must-not-copy', 'utf8'); + + const plan = buildSyncPlan( + normalizeSyncConfig({ + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: [localPath], + }), + locations, + repoRoot, + 'linux' + ); + await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true }); + await fs.writeFile( + plan.extraConfigs.manifestPath, + JSON.stringify({ + entries: [ + { + sourcePath: 'custom.json', + repoPath: '../outside-payload', + type: 'file', + }, + ], + }), + 'utf8' + ); + + await syncRepoToLocal(plan, null); + await expect(fs.stat(localPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); +}); + describe('MCP secret scrub round trip', () => { it('keeps the secret local, writes a placeholder to the repo, and protects overrides', async () => { await withTempDir(async (root) => { diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 2afdbc5..897329a 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -23,7 +23,7 @@ import { stripOverrideKeys, } from './mcp-secrets.js'; import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; -import { normalizePath } from './paths.js'; +import { fromPortablePath, normalizePath, toPortablePath } from './paths.js'; type ExtraPathType = 'file' | 'dir'; @@ -577,14 +577,14 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise(manifestContent); for (const entry of manifest.entries) { - const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform); + const pathApi = plan.platform === 'win32' ? path.win32 : path.posix; + const localPath = fromPortablePath(entry.sourcePath, plan.configRoot, plan.homeDir, pathApi); + const normalized = normalizePath(localPath, plan.homeDir, plan.platform); const isAllowed = allowlist.includes(normalized); if (!isAllowed) continue; - const repoPath = path.isAbsolute(entry.repoPath) - ? entry.repoPath - : path.join(plan.repoRoot, entry.repoPath); - const localPath = entry.sourcePath; + const repoPath = resolveManifestRepoPath(plan.repoRoot, entry.repoPath); + if (!repoPath) continue; const entryType: ExtraPathType = entry.type ?? 'file'; if (!(await pathExists(repoPath))) continue; @@ -613,12 +613,15 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro continue; } const stat = await fs.stat(sourcePath); + const pathApi = plan.platform === 'win32' ? path.win32 : path.posix; + const portableSourcePath = toPortablePath(sourcePath, plan.configRoot, plan.homeDir, pathApi); + const manifestRepoPath = toManifestRepoPath(plan.repoRoot, entry.repoPath); if (stat.isDirectory()) { await copyDirRecursive(sourcePath, entry.repoPath); const items = await collectExtraPathItems(sourcePath, sourcePath); entries.push({ - sourcePath, - repoPath: path.relative(plan.repoRoot, entry.repoPath), + sourcePath: portableSourcePath, + repoPath: manifestRepoPath, type: 'dir', mode: stat.mode & 0o777, items, @@ -628,8 +631,8 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro if (stat.isFile()) { await copyFileWithMode(sourcePath, entry.repoPath); entries.push({ - sourcePath, - repoPath: path.relative(plan.repoRoot, entry.repoPath), + sourcePath: portableSourcePath, + repoPath: manifestRepoPath, type: 'file', mode: stat.mode & 0o777, }); @@ -649,7 +652,7 @@ async function collectExtraPathItems( for (const entry of entries) { const entrySource = path.join(sourcePath, entry.name); - const relativePath = path.relative(basePath, entrySource); + const relativePath = toPortableSeparators(path.relative(basePath, entrySource)); if (entry.isDirectory()) { const stat = await fs.stat(entrySource); @@ -702,10 +705,11 @@ async function applyExtraPathModes( function resolveExtraPathItem(basePath: string, relativePath: string): string | null { if (!relativePath) return null; - if (path.isAbsolute(relativePath)) return null; + const normalizedRelativePath = toPortableSeparators(relativePath); + if (path.posix.isAbsolute(normalizedRelativePath)) return null; const resolvedBase = path.resolve(basePath); - const resolvedPath = path.resolve(basePath, relativePath); + const resolvedPath = path.resolve(basePath, normalizedRelativePath); const relative = path.relative(resolvedBase, resolvedPath); if (relative === '..' || relative.startsWith(`..${path.sep}`)) { return null; @@ -717,6 +721,33 @@ function resolveExtraPathItem(basePath: string, relativePath: string): string | return resolvedPath; } +function resolveManifestRepoPath(repoRoot: string, manifestRepoPath: string): string | null { + if (!manifestRepoPath) return null; + + const resolvedRoot = path.resolve(repoRoot); + const portableRepoPath = toPortableSeparators(manifestRepoPath); + const resolvedPath = path.isAbsolute(manifestRepoPath) + ? path.resolve(manifestRepoPath) + : path.resolve(resolvedRoot, portableRepoPath); + const relative = path.relative(resolvedRoot, resolvedPath); + if (relative === '..' || relative.startsWith(`..${path.sep}`)) return null; + if (path.isAbsolute(relative)) return null; + + return resolvedPath; +} + +function toManifestRepoPath(repoRoot: string, repoPath: string): string { + const containedPath = resolveManifestRepoPath(repoRoot, repoPath); + if (!containedPath) { + throw new Error(`Extra path repository target is outside the sync repo: ${repoPath}`); + } + return toPortableSeparators(path.relative(path.resolve(repoRoot), containedPath)); +} + +function toPortableSeparators(inputPath: string): string { + return inputPath.replace(/\\/g, '/'); +} + function isDeepEqual(left: unknown, right: unknown): boolean { if (left === right) return true; if (typeof left !== typeof right) return false; diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 47e0527..e400461 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -8,12 +8,14 @@ import { normalizeSyncConfig } from './config.js'; import { buildSyncPlan, expandHome, + fromPortablePath, normalizePath, resolveExtraPath, resolveHomeDir, resolveRepoRoot, resolveSyncLocations, resolveXdgPaths, + toPortablePath, } from './paths.js'; describe('resolveHomeDir', () => { @@ -643,3 +645,247 @@ describe('buildSyncPlan', () => { expect(disabledItem).toBeUndefined(); }); }); + +describe('toPortablePath', () => { + it('converts configRoot-absolute path to relative', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = toPortablePath( + '/home/test/.config/opencode/tui.json', + configRoot, + homeDir, + path.posix + ); + expect(result).toBe('tui.json'); + }); + + it('converts nested configRoot path to relative', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = toPortablePath( + '/home/test/.config/opencode/sub/plugin.json', + configRoot, + homeDir, + path.posix + ); + expect(result).toBe('sub/plugin.json'); + }); + + it('converts path outside configRoot but inside homeDir to ~/ prefix', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = toPortablePath('/home/test/.ssh/id_rsa', configRoot, homeDir, path.posix); + expect(result).toBe('~/.ssh/id_rsa'); + }); + + it('keeps absolute path if outside both configRoot and homeDir', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = toPortablePath('/opt/some/config.json', configRoot, homeDir, path.posix); + expect(result).toBe('/opt/some/config.json'); + }); + + it('handles homeDir itself as ~', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = toPortablePath('/home/test', configRoot, homeDir, path.posix); + expect(result).toBe('~'); + }); + + it('encodes configRoot itself as an unambiguous relative marker', () => { + const configRoot = '/home/test/.config/opencode'; + expect(toPortablePath(configRoot, configRoot, '/home/test', path.posix)).toBe('.'); + }); + + it('uses canonical separators for Windows-authored paths', () => { + const configRoot = 'C:\\Users\\Test\\AppData\\Roaming\\opencode'; + const homeDir = 'C:\\Users\\Test'; + + expect( + toPortablePath( + 'C:\\Users\\Test\\AppData\\Roaming\\opencode\\plugins\\custom.ts', + configRoot, + homeDir, + path.win32 + ) + ).toBe('plugins/custom.ts'); + expect(toPortablePath('D:\\shared\\custom.json', configRoot, homeDir, path.win32)).toBe( + 'D:/shared/custom.json' + ); + }); + + it('compares Windows config and home roots without case sensitivity', () => { + const configRoot = 'C:\\Users\\Test\\AppData\\Roaming\\opencode'; + const homeDir = 'C:\\Users\\Test'; + + expect( + toPortablePath( + 'c:\\users\\test\\appdata\\roaming\\opencode\\Themes\\custom.json', + configRoot, + homeDir, + path.win32 + ) + ).toBe('Themes/custom.json'); + expect( + toPortablePath('c:\\users\\test\\Documents\\opencode.json', configRoot, homeDir, path.win32) + ).toBe('~/Documents/opencode.json'); + }); +}); + +describe('fromPortablePath', () => { + it('resolves relative path against configRoot', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = fromPortablePath('tui.json', configRoot, homeDir, path.posix); + expect(result).toBe('/home/test/.config/opencode/tui.json'); + }); + + it('resolves nested relative path against configRoot', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = fromPortablePath('sub/plugin.json', configRoot, homeDir, path.posix); + expect(result).toBe('/home/test/.config/opencode/sub/plugin.json'); + }); + + it('expands ~/ to homeDir', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = fromPortablePath('~/.ssh/id_rsa', configRoot, homeDir, path.posix); + expect(result).toBe('/home/test/.ssh/id_rsa'); + }); + + it('expands ~ to homeDir', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = fromPortablePath('~', configRoot, homeDir, path.posix); + expect(result).toBe('/home/test'); + }); + + it('resolves the configRoot marker', () => { + const configRoot = '/home/test/.config/opencode'; + expect(fromPortablePath('.', configRoot, '/home/test', path.posix)).toBe(configRoot); + }); + + it('keeps absolute path as-is (backwards compat)', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const result = fromPortablePath( + '/Users/khangnghiem/.config/opencode/tui.json', + configRoot, + homeDir, + path.posix + ); + expect(result).toBe('/Users/khangnghiem/.config/opencode/tui.json'); + }); + + it('round-trips configRoot path correctly', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const original = '/home/test/.config/opencode/tui.json'; + const portable = toPortablePath(original, configRoot, homeDir, path.posix); + const restored = fromPortablePath(portable, configRoot, homeDir, path.posix); + expect(restored).toBe(original); + }); + + it('round-trips homeDir path correctly', () => { + const configRoot = '/home/test/.config/opencode'; + const homeDir = '/home/test'; + const original = '/home/test/.ssh/id_rsa'; + const portable = toPortablePath(original, configRoot, homeDir, path.posix); + const restored = fromPortablePath(portable, configRoot, homeDir, path.posix); + expect(restored).toBe(original); + }); + + it('round-trips across different machines (macOS push, Linux pull)', () => { + // Push on macOS + const macConfigRoot = '/Users/khangnghiem/.config/opencode'; + const macHomeDir = '/Users/khangnghiem'; + const macPath = '/Users/khangnghiem/.config/opencode/tui.json'; + const portable = toPortablePath(macPath, macConfigRoot, macHomeDir, path.posix); + expect(portable).toBe('tui.json'); + + // Pull on Linux + const linuxConfigRoot = '/home/linuxuser/.config/opencode'; + const linuxHomeDir = '/home/linuxuser'; + const linuxPath = fromPortablePath(portable, linuxConfigRoot, linuxHomeDir, path.posix); + expect(linuxPath).toBe('/home/linuxuser/.config/opencode/tui.json'); + }); + + it('round-trips across different machines with ~/ path (macOS push, Windows pull)', () => { + // Push on macOS (path outside configRoot, inside homeDir) + const macConfigRoot = '/Users/khangnghiem/.config/opencode'; + const macHomeDir = '/Users/khangnghiem'; + const macPath = '/Users/khangnghiem/.ssh/id_rsa'; + const portable = toPortablePath(macPath, macConfigRoot, macHomeDir, path.posix); + expect(portable).toBe('~/.ssh/id_rsa'); + + // Pull on Windows (~/ expands to local USERPROFILE) + const winConfigRoot = 'C:\\Users\\test\\AppData\\Roaming\\opencode'; + const winHomeDir = 'C:\\Users\\test'; + const winPath = fromPortablePath(portable, winConfigRoot, winHomeDir, path.win32); + expect(winPath).toBe('C:\\Users\\test\\.ssh\\id_rsa'); + }); + + it('round-trips a Windows config path to a POSIX home', () => { + const portable = toPortablePath( + 'C:\\Users\\Alice\\AppData\\Roaming\\opencode\\plugins\\custom.ts', + 'C:\\Users\\Alice\\AppData\\Roaming\\opencode', + 'C:\\Users\\Alice', + path.win32 + ); + + expect(portable).toBe('plugins/custom.ts'); + expect(fromPortablePath(portable, '/home/bob/.config/opencode', '/home/bob', path.posix)).toBe( + '/home/bob/.config/opencode/plugins/custom.ts' + ); + }); + + it('round-trips a POSIX config path to a Windows home', () => { + const portable = toPortablePath( + '/home/alice/.config/opencode/plugins/custom.ts', + '/home/alice/.config/opencode', + '/home/alice', + path.posix + ); + + expect(portable).toBe('plugins/custom.ts'); + expect( + fromPortablePath( + portable, + 'C:\\Users\\Bob\\AppData\\Roaming\\opencode', + 'C:\\Users\\Bob', + path.win32 + ) + ).toBe('C:\\Users\\Bob\\AppData\\Roaming\\opencode\\plugins\\custom.ts'); + }); + + it('recognizes foreign absolute paths without resolving them under configRoot', () => { + expect( + fromPortablePath( + 'C:/Users/Alice/external.json', + '/home/bob/.config/opencode', + '/home/bob', + path.posix + ) + ).toBe('C:/Users/Alice/external.json'); + expect( + fromPortablePath( + '/opt/opencode/external.json', + 'C:\\Users\\Bob\\AppData\\Roaming\\opencode', + 'C:\\Users\\Bob', + path.win32 + ) + ).toBe('/opt/opencode/external.json'); + }); + + it('accepts legacy Windows separators in home-relative manifest paths', () => { + expect( + fromPortablePath( + '~\\Documents\\opencode.json', + 'C:\\Users\\Bob\\AppData\\Roaming\\opencode', + 'C:\\Users\\Bob', + path.win32 + ) + ).toBe('C:\\Users\\Bob\\Documents\\opencode.json'); + }); +}); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index ccb74dc..a35f487 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -44,6 +44,7 @@ export interface SyncPlan { repoRoot: string; homeDir: string; platform: NodeJS.Platform; + configRoot: string; } const DEFAULT_CONFIG_NAME = 'opencode.json'; @@ -380,6 +381,7 @@ export function buildSyncPlan( repoRoot, homeDir: locations.xdg.homeDir, platform, + configRoot: locations.configRoot, }; } @@ -400,3 +402,92 @@ function buildExtraPathPlan( entries, }; } + +/** + * Convert an absolute sourcePath to a portable form for the manifest. + * If the path is configRoot, store it as ".". Paths inside it are relative. + * If outside configRoot, store it with ~/ prefix (e.g. "~/.ssh/id_rsa"). + * Manifest separators are always forward slashes. + */ +export function toPortablePath( + absolutePath: string, + configRoot: string, + homeDir: string, + pathApi: typeof path.posix = process.platform === 'win32' ? path.win32 : path.posix +): string { + const relativeToConfig = relativeIfContained(absolutePath, configRoot, pathApi); + if (relativeToConfig !== null) { + return relativeToConfig === '' ? '.' : toPortableSeparators(relativeToConfig); + } + + const relativeToHome = homeDir ? relativeIfContained(absolutePath, homeDir, pathApi) : null; + if (relativeToHome !== null) { + return relativeToHome === '' ? '~' : `~/${toPortableSeparators(relativeToHome)}`; + } + + return toPortableSeparators(absolutePath); +} + +/** + * Resolve a portable sourcePath from the manifest back to a local absolute path. + * "." and relative paths are resolved against configRoot. + * ~/ paths are expanded via the local homeDir. + * Absolute POSIX, drive-letter, and UNC paths remain absolute for backwards compatibility. + */ +export function fromPortablePath( + portablePath: string, + configRoot: string, + homeDir: string, + pathApi: typeof path.posix = process.platform === 'win32' ? path.win32 : path.posix +): string { + if (!portablePath) return portablePath; + + if (portablePath === '.') return pathApi.resolve(configRoot); + if (portablePath === '~') return pathApi.resolve(homeDir); + if (portablePath.startsWith('~/') || portablePath.startsWith('~\\')) { + return pathApi.resolve(homeDir, portablePath.slice(2)); + } + + if (isAbsoluteOnAnyPlatform(portablePath)) { + return portablePath; + } + + return pathApi.resolve(configRoot, portablePath); +} + +function relativeIfContained( + candidatePath: string, + rootPath: string, + pathApi: typeof path.posix +): string | null { + const resolvedCandidate = pathApi.resolve(candidatePath); + const resolvedRoot = pathApi.resolve(rootPath); + const comparisonCandidate = normalizeForComparison(resolvedCandidate, pathApi); + const comparisonRoot = normalizeForComparison(resolvedRoot, pathApi); + const comparisonRelative = pathApi.relative(comparisonRoot, comparisonCandidate); + + if ( + comparisonRelative === '..' || + comparisonRelative.startsWith(`..${pathApi.sep}`) || + pathApi.isAbsolute(comparisonRelative) + ) { + return null; + } + + return pathApi.relative(resolvedRoot, resolvedCandidate); +} + +function normalizeForComparison(inputPath: string, pathApi: typeof path.posix): string { + const normalized = pathApi.normalize(inputPath); + return pathApi === path.win32 ? normalized.toLowerCase() : normalized; +} + +function isAbsoluteOnAnyPlatform(inputPath: string): boolean { + if (path.posix.isAbsolute(inputPath)) return true; + if (/^[a-zA-Z]:[\\/]/.test(inputPath)) return true; + return /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/.test(inputPath); +} + +function toPortableSeparators(inputPath: string): string { + return inputPath.replace(/\\/g, '/'); +}