diff --git a/packages/boxel-cli/src/build-program.ts b/packages/boxel-cli/src/build-program.ts index 74c9ed3c03a..38921e9a46c 100644 --- a/packages/boxel-cli/src/build-program.ts +++ b/packages/boxel-cli/src/build-program.ts @@ -1,11 +1,13 @@ import { Command } from 'commander'; import { profileCommand } from './commands/profile'; +import { registerConsolidateWorkspacesCommand } from './commands/consolidate-workspaces'; import { registerReadTranspiledCommand } from './commands/read-transpiled'; import { registerRealmCommand } from './commands/realm/index'; import { registerFileCommand } from './commands/file/index'; import { registerRunCommand } from './commands/run-command'; import { registerSearchCommand } from './commands/search'; import { setQuiet } from './lib/cli-log'; +import { warnIfMisplacedLocalRealmDirs } from './lib/realm-local-paths'; /** * Construct the boxel CLI program with every command registered. Pure builder @@ -30,6 +32,7 @@ export function buildBoxelProgram(version: string): Command { if (opts.quiet) { setQuiet(true); } + warnIfMisplacedLocalRealmDirs(process.cwd()); }); program @@ -86,6 +89,7 @@ Environment variables (for 'add'): registerRunCommand(program); registerSearchCommand(program); registerReadTranspiledCommand(program); + registerConsolidateWorkspacesCommand(program); return program; } diff --git a/packages/boxel-cli/src/commands/consolidate-workspaces.ts b/packages/boxel-cli/src/commands/consolidate-workspaces.ts new file mode 100644 index 00000000000..bc3c7d67395 --- /dev/null +++ b/packages/boxel-cli/src/commands/consolidate-workspaces.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Command } from 'commander'; +import { findMisplacedLocalRealmDirs } from '../lib/realm-local-paths'; + +export interface ConsolidateWorkspacesOptions { + dryRun?: boolean; +} + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function moveDir(from: string, to: string): void { + try { + fs.renameSync(from, to); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'EXDEV') { + throw err; + } + fs.cpSync(from, to, { recursive: true }); + fs.rmSync(from, { recursive: true, force: true }); + } +} + +export async function consolidateWorkspacesCommand( + rootDirInput: string | undefined, + options: ConsolidateWorkspacesOptions, +): Promise { + const rootDir = path.resolve(rootDirInput || '.'); + const entries = findMisplacedLocalRealmDirs(rootDir); + + if (entries.length === 0) { + console.log(`No misplaced local realm paths found under ${rootDir}`); + return; + } + + console.log(`Found ${entries.length} misplaced local realm path(s):\n`); + + let moved = 0; + let skipped = 0; + + for (const entry of entries) { + const from = path.relative(rootDir, entry.currentDir) || '.'; + const to = path.relative(rootDir, entry.expectedDir) || '.'; + console.log(`- ${from} -> ${to}`); + + if (options.dryRun) { + continue; + } + + if (fs.existsSync(entry.expectedDir)) { + console.warn(' Skipping: target path already exists'); + skipped += 1; + continue; + } + + ensureDir(path.dirname(entry.expectedDir)); + try { + moveDir(entry.currentDir, entry.expectedDir); + moved += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(` Skipping: failed to move (${message})`); + skipped += 1; + } + } + + if (options.dryRun) { + console.log('\n[DRY RUN] No directories moved.'); + return; + } + + console.log(`\nMoved ${moved} director${moved === 1 ? 'y' : 'ies'}.`); + if (skipped > 0) { + console.log( + `Skipped ${skipped} due to existing target paths or move failures.`, + ); + } +} + +export function registerConsolidateWorkspacesCommand(program: Command): void { + program + .command('consolidate-workspaces') + .description( + 'Move local realm mirror directories into the canonical /// layout', + ) + .argument( + '[root-dir]', + 'Root directory to scan (default: current directory)', + ) + .option('--dry-run', 'Preview without moving anything') + .action( + async ( + rootDir: string | undefined, + opts: ConsolidateWorkspacesOptions, + ) => { + await consolidateWorkspacesCommand(rootDir, opts); + }, + ); +} diff --git a/packages/boxel-cli/src/lib/realm-local-paths.ts b/packages/boxel-cli/src/lib/realm-local-paths.ts new file mode 100644 index 00000000000..aed47d1b9e8 --- /dev/null +++ b/packages/boxel-cli/src/lib/realm-local-paths.ts @@ -0,0 +1,243 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { isQuiet } from './cli-log'; + +export interface MisplacedLocalRealmEntry { + manifestPath: string; + currentDir: string; + expectedDir: string; + realmUrl: string; +} + +interface MinimalManifest { + realmUrl: string; +} + +let didWarnInProcess = false; + +const SKIPPABLE_DIR_NAMES = new Set([ + '.git', + 'node_modules', + 'dist', + '.boxel-history', + '.claude', +]); + +function isSkippableDir(dirName: string): boolean { + return SKIPPABLE_DIR_NAMES.has(dirName); +} + +function canonicalDomainFromHost(hostname: string): string { + if (hostname === 'stack.cards' || hostname.endsWith('.stack.cards')) { + return 'stack.cards'; + } + if (hostname === 'boxel.ai' || hostname.endsWith('.boxel.ai')) { + return 'boxel.ai'; + } + return hostname; +} + +// Reject segments that would let a crafted realmUrl escape the rootDir tree +// (`.`, `..`, anything containing a path separator or NUL) — defence in depth +// against malformed manifests; `findMisplacedLocalRealmDirs` also re-checks +// containment after `path.resolve` collapses any `..` it didn't catch. +function isSafePathSegment(segment: string): boolean { + if (!segment) return false; + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return false; + } + if (decoded === '.' || decoded === '..') return false; + if ( + decoded.includes('/') || + decoded.includes('\\') || + decoded.includes('\0') + ) { + return false; + } + return true; +} + +export function relativeStructuredPathForRealmUrl( + realmUrl: string, +): string | null { + let url: URL; + try { + url = new URL(realmUrl); + } catch { + return null; + } + const domain = canonicalDomainFromHost(url.hostname); + if (!isSafePathSegment(domain)) return null; + const parts = url.pathname + .replace(/^\/|\/$/g, '') + .split('/') + .filter(Boolean); + const owner = parts[0] ?? 'unknown-owner'; + const realm = parts[1] ?? parts[0] ?? 'workspace'; + if (!isSafePathSegment(owner) || !isSafePathSegment(realm)) return null; + return path.join(domain, owner, realm); +} + +export function absoluteStructuredPathForRealmUrl( + realmUrl: string, + rootDir: string, +): string | null { + const rel = relativeStructuredPathForRealmUrl(realmUrl); + if (rel === null) return null; + return path.resolve(rootDir, rel); +} + +function tryReadRealmUrl(manifestPath: string): MinimalManifest | null { + let content: string; + try { + content = fs.readFileSync(manifestPath, 'utf-8'); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const candidate = (parsed as Record).realmUrl; + if (typeof candidate !== 'string' || candidate === '') { + return null; + } + return { realmUrl: candidate }; +} + +function addManifestIfExists(dir: string, manifests: string[]): void { + const manifestPath = path.join(dir, '.boxel-sync.json'); + if (fs.existsSync(manifestPath)) { + manifests.push(manifestPath); + } +} + +function listSubdirs(dir: string): string[] { + try { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !isSkippableDir(entry.name)) + .map((entry) => path.join(dir, entry.name)); + } catch { + return []; + } +} + +function findManifestPaths(rootDir: string): string[] { + const manifests: string[] = []; + const absoluteRoot = path.resolve(rootDir); + + // Legacy layout: //.boxel-sync.json + for (const childDir of listSubdirs(absoluteRoot)) { + addManifestIfExists(childDir, manifests); + } + + // Canonical layout: ////.boxel-sync.json + for (const domainDir of listSubdirs(absoluteRoot)) { + for (const ownerDir of listSubdirs(domainDir)) { + for (const realmDir of listSubdirs(ownerDir)) { + addManifestIfExists(realmDir, manifests); + } + } + } + + return manifests; +} + +// True iff `child` is `root` or a descendant of `root`. Belt-and-suspenders +// containment check after `path.resolve` — even if a crafted realmUrl made +// it past `isSafePathSegment`, the resolved path must stay inside rootDir +// before we move anything. +function isWithin(root: string, child: string): boolean { + const rel = path.relative(root, child); + if (rel === '') return true; + if (rel.startsWith('..')) return false; + return !path.isAbsolute(rel); +} + +export function findMisplacedLocalRealmDirs( + rootDir: string, +): MisplacedLocalRealmEntry[] { + const absoluteRoot = path.resolve(rootDir); + const manifestPaths = findManifestPaths(absoluteRoot); + + const seenManifestPaths = new Set(); + const entries: MisplacedLocalRealmEntry[] = []; + for (const manifestPath of manifestPaths) { + if (seenManifestPaths.has(manifestPath)) { + continue; + } + seenManifestPaths.add(manifestPath); + + const manifest = tryReadRealmUrl(manifestPath); + if (!manifest) { + continue; + } + + const expectedDir = absoluteStructuredPathForRealmUrl( + manifest.realmUrl, + absoluteRoot, + ); + if (expectedDir === null) { + continue; + } + if (!isWithin(absoluteRoot, expectedDir)) { + continue; + } + + const currentDir = path.dirname(manifestPath); + if (path.resolve(currentDir) !== path.resolve(expectedDir)) { + entries.push({ + manifestPath, + currentDir, + expectedDir, + realmUrl: manifest.realmUrl, + }); + } + } + + return entries; +} + +export function warnIfMisplacedLocalRealmDirs(rootDir: string): void { + if (didWarnInProcess) return; + if (process.env.BOXEL_DISABLE_PATH_WARNING === '1') return; + if (isQuiet()) return; + + const entries = findMisplacedLocalRealmDirs(rootDir); + if (entries.length === 0) return; + + didWarnInProcess = true; + + console.warn('\n⚠️ Detected local realm directories at legacy local paths:'); + const absoluteRoot = path.resolve(rootDir); + for (const entry of entries.slice(0, 5)) { + const from = path.relative(absoluteRoot, entry.currentDir) || '.'; + const to = path.relative(absoluteRoot, entry.expectedDir) || '.'; + console.warn(` - ${from} -> ${to}`); + } + if (entries.length > 5) { + console.warn(` ...and ${entries.length - 5} more`); + } + console.warn('\nRun to preview:'); + console.warn(' boxel consolidate-workspaces . --dry-run'); + console.warn('Then apply:'); + console.warn(' boxel consolidate-workspaces .\n'); +} + +/** + * Test-only escape hatch — resets the once-per-process warning latch so tests + * can exercise `warnIfMisplacedLocalRealmDirs` repeatedly within a single + * Node process. + */ +export function resetWarnedFlagForTests(): void { + didWarnInProcess = false; +} diff --git a/packages/boxel-cli/tests/commands/consolidate-workspaces.test.ts b/packages/boxel-cli/tests/commands/consolidate-workspaces.test.ts new file mode 100644 index 00000000000..f7edeeca4d9 --- /dev/null +++ b/packages/boxel-cli/tests/commands/consolidate-workspaces.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { consolidateWorkspacesCommand } from '../../src/commands/consolidate-workspaces.js'; + +function writeManifest(dir: string, realmUrl: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.boxel-sync.json'), + JSON.stringify({ realmUrl, files: {} }), + ); +} + +describe('consolidateWorkspacesCommand', () => { + let tmpRoot: string; + let logSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-consolidate-')); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('moves a legacy-layout directory into the canonical // path', async () => { + const legacyDir = path.join(tmpRoot, 'old-notes'); + writeManifest(legacyDir, 'https://stack.cards/alice/notes'); + + await consolidateWorkspacesCommand(tmpRoot, {}); + + const targetDir = path.join(tmpRoot, 'stack.cards', 'alice', 'notes'); + expect(fs.existsSync(targetDir)).toBe(true); + expect(fs.existsSync(path.join(targetDir, '.boxel-sync.json'))).toBe(true); + expect(fs.existsSync(legacyDir)).toBe(false); + }); + + it('does not move anything when --dry-run is set', async () => { + const legacyDir = path.join(tmpRoot, 'old-notes'); + writeManifest(legacyDir, 'https://stack.cards/alice/notes'); + + await consolidateWorkspacesCommand(tmpRoot, { dryRun: true }); + + expect(fs.existsSync(legacyDir)).toBe(true); + expect( + fs.existsSync(path.join(tmpRoot, 'stack.cards', 'alice', 'notes')), + ).toBe(false); + expect( + logSpy.mock.calls.some((args) => + String(args[0] ?? '').includes('[DRY RUN]'), + ), + ).toBe(true); + }); + + it('reports "no misplaced" and moves nothing when all dirs are already canonical', async () => { + const canonicalDir = path.join(tmpRoot, 'stack.cards', 'alice', 'notes'); + writeManifest(canonicalDir, 'https://stack.cards/alice/notes'); + + await consolidateWorkspacesCommand(tmpRoot, {}); + + expect(fs.existsSync(canonicalDir)).toBe(true); + expect( + logSpy.mock.calls.some((args) => + String(args[0] ?? '') + .toLowerCase() + .includes('no misplaced'), + ), + ).toBe(true); + }); + + it('skips an entry whose target path already exists and continues with others', async () => { + // misplaced-a points at the same canonical destination as existing-target, + // which already lives at the canonical path. misplaced-b's destination is free. + const occupiedTarget = path.join(tmpRoot, 'stack.cards', 'alice', 'notes'); + writeManifest(occupiedTarget, 'https://stack.cards/alice/notes'); + + const conflictDir = path.join(tmpRoot, 'misplaced-a'); + writeManifest(conflictDir, 'https://stack.cards/alice/notes'); + + const freeMisplacedDir = path.join(tmpRoot, 'misplaced-b'); + writeManifest(freeMisplacedDir, 'https://stack.cards/bob/other'); + + await consolidateWorkspacesCommand(tmpRoot, {}); + + // The conflicting source remains in place — destination was occupied. + expect(fs.existsSync(conflictDir)).toBe(true); + // The free one moved to its canonical destination. + expect(fs.existsSync(freeMisplacedDir)).toBe(false); + expect( + fs.existsSync(path.join(tmpRoot, 'stack.cards', 'bob', 'other')), + ).toBe(true); + // A skip warning was emitted for the conflict. + expect( + warnSpy.mock.calls.some((args) => + String(args[0] ?? '') + .toLowerCase() + .includes('target path already exists'), + ), + ).toBe(true); + }); + + it('does not throw when the root directory does not exist', async () => { + const missing = path.join(tmpRoot, 'does-not-exist'); + + await expect( + consolidateWorkspacesCommand(missing, {}), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/boxel-cli/tests/lib/realm-local-paths.test.ts b/packages/boxel-cli/tests/lib/realm-local-paths.test.ts new file mode 100644 index 00000000000..039d31d320a --- /dev/null +++ b/packages/boxel-cli/tests/lib/realm-local-paths.test.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + absoluteStructuredPathForRealmUrl, + findMisplacedLocalRealmDirs, + relativeStructuredPathForRealmUrl, + resetWarnedFlagForTests, + warnIfMisplacedLocalRealmDirs, +} from '../../src/lib/realm-local-paths.js'; +import { setQuiet } from '../../src/lib/cli-log.js'; + +function writeManifest(dir: string, body: unknown): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, '.boxel-sync.json'), + typeof body === 'string' ? body : JSON.stringify(body), + ); +} + +describe('relativeStructuredPathForRealmUrl', () => { + it('maps a canonical stack.cards URL to //', () => { + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/alice/notes'), + ).toBe(path.join('stack.cards', 'alice', 'notes')); + }); + + it('folds *.stack.cards subdomains to stack.cards', () => { + expect( + relativeStructuredPathForRealmUrl( + 'https://staging.stack.cards/alice/notes', + ), + ).toBe(path.join('stack.cards', 'alice', 'notes')); + }); + + it('folds *.boxel.ai subdomains to boxel.ai', () => { + expect( + relativeStructuredPathForRealmUrl('https://foo.boxel.ai/bob/realm'), + ).toBe(path.join('boxel.ai', 'bob', 'realm')); + }); + + it('passes other hostnames through unchanged', () => { + expect( + relativeStructuredPathForRealmUrl('https://custom.example.org/u/r'), + ).toBe(path.join('custom.example.org', 'u', 'r')); + }); + + it('ignores trailing slashes in the URL path', () => { + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/alice/notes/'), + ).toBe(path.join('stack.cards', 'alice', 'notes')); + }); + + it('uses defaults when the URL has only one path segment', () => { + expect(relativeStructuredPathForRealmUrl('https://stack.cards/only')).toBe( + path.join('stack.cards', 'only', 'only'), + ); + }); + + it('uses defaults when the URL has an empty path', () => { + expect(relativeStructuredPathForRealmUrl('https://stack.cards')).toBe( + path.join('stack.cards', 'unknown-owner', 'workspace'), + ); + }); + + it('returns null for a string that is not a parseable URL', () => { + expect(relativeStructuredPathForRealmUrl('not a url')).toBeNull(); + expect(relativeStructuredPathForRealmUrl('')).toBeNull(); + }); + + it('returns null for segments containing encoded path separators or NUL', () => { + // `%2F` decodes to `/`, `%5C` to `\`, `%00` to NUL — any of these in an + // owner/realm segment could be used to construct a destination outside + // the intended // tree if we let them through. + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/foo%2Fbar/baz'), + ).toBeNull(); + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/alice/foo%5Cbar'), + ).toBeNull(); + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/foo%00bar/baz'), + ).toBeNull(); + }); + + it('produces a safely-rooted path even for traversal-shaped URLs (WHATWG normalizes ..)', () => { + // The URL parser collapses `..` to `/`; the result lives under + // `/stack.cards/...` and never escapes. + expect(relativeStructuredPathForRealmUrl('https://stack.cards/../..')).toBe( + path.join('stack.cards', 'unknown-owner', 'workspace'), + ); + expect( + relativeStructuredPathForRealmUrl('https://stack.cards/%2e%2e/foo'), + ).toBe(path.join('stack.cards', 'foo', 'foo')); + }); +}); + +describe('absoluteStructuredPathForRealmUrl', () => { + it('resolves the structured path against the supplied root', () => { + expect( + absoluteStructuredPathForRealmUrl( + 'https://stack.cards/alice/notes', + '/tmp/root', + ), + ).toBe(path.resolve('/tmp/root', 'stack.cards', 'alice', 'notes')); + }); + + it('returns null for an unparseable realmUrl', () => { + expect( + absoluteStructuredPathForRealmUrl('not a url', '/tmp/root'), + ).toBeNull(); + }); +}); + +describe('findMisplacedLocalRealmDirs', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-rlp-find-')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('returns [] for an empty root', () => { + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('returns [] when the root does not exist', () => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('finds a legacy-layout directory and computes the canonical expectedDir', () => { + const legacyDir = path.join(tmpRoot, 'old-notes'); + writeManifest(legacyDir, { realmUrl: 'https://stack.cards/alice/notes' }); + + const entries = findMisplacedLocalRealmDirs(tmpRoot); + expect(entries).toHaveLength(1); + expect(entries[0].currentDir).toBe(legacyDir); + expect(entries[0].expectedDir).toBe( + path.join(tmpRoot, 'stack.cards', 'alice', 'notes'), + ); + expect(entries[0].realmUrl).toBe('https://stack.cards/alice/notes'); + }); + + it('returns [] when a directory is already in the canonical layout at the right path', () => { + const canonicalDir = path.join(tmpRoot, 'stack.cards', 'alice', 'notes'); + writeManifest(canonicalDir, { + realmUrl: 'https://stack.cards/alice/notes', + }); + + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('returns only the misplaced entries when the tree mixes correct and incorrect dirs', () => { + writeManifest(path.join(tmpRoot, 'stack.cards', 'alice', 'good'), { + realmUrl: 'https://stack.cards/alice/good', + }); + writeManifest(path.join(tmpRoot, 'misplaced'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + + const entries = findMisplacedLocalRealmDirs(tmpRoot); + expect(entries.map((e) => e.currentDir)).toEqual([ + path.join(tmpRoot, 'misplaced'), + ]); + }); + + it('does not descend into skippable directories like .git or node_modules', () => { + writeManifest(path.join(tmpRoot, '.git'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + writeManifest(path.join(tmpRoot, 'node_modules'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('ignores manifests that lack realmUrl', () => { + writeManifest(path.join(tmpRoot, 'bad-no-url'), { files: {} }); + writeManifest(path.join(tmpRoot, 'bad-empty-url'), { realmUrl: '' }); + + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('ignores unparseable manifest JSON without throwing', () => { + writeManifest(path.join(tmpRoot, 'malformed'), '{not json'); + + expect(() => findMisplacedLocalRealmDirs(tmpRoot)).not.toThrow(); + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('ignores manifests whose realmUrl is not a parseable URL (no throw)', () => { + writeManifest(path.join(tmpRoot, 'bad-url'), { + realmUrl: 'not even a url', + }); + + expect(() => findMisplacedLocalRealmDirs(tmpRoot)).not.toThrow(); + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('ignores manifests with encoded path separators in segments', () => { + writeManifest(path.join(tmpRoot, 'sneaky-slash'), { + realmUrl: 'https://stack.cards/foo%2Fbar/baz', + }); + + expect(findMisplacedLocalRealmDirs(tmpRoot)).toEqual([]); + }); + + it('keeps traversal-shaped URLs inside rootDir (WHATWG normalizes ..)', () => { + writeManifest(path.join(tmpRoot, 'traversal-attempt'), { + realmUrl: 'https://stack.cards/../../etc/passwd', + }); + + const entries = findMisplacedLocalRealmDirs(tmpRoot); + expect(entries).toHaveLength(1); + // expectedDir must stay strictly within tmpRoot, never escape. + expect(entries[0].expectedDir.startsWith(path.resolve(tmpRoot))).toBe(true); + expect( + path + .relative(path.resolve(tmpRoot), entries[0].expectedDir) + .startsWith('..'), + ).toBe(false); + }); +}); + +describe('warnIfMisplacedLocalRealmDirs', () => { + let tmpRoot: string; + let warnSpy: ReturnType; + const ORIGINAL_DISABLE = process.env.BOXEL_DISABLE_PATH_WARNING; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-rlp-warn-')); + resetWarnedFlagForTests(); + setQuiet(false); + delete process.env.BOXEL_DISABLE_PATH_WARNING; + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + setQuiet(false); + resetWarnedFlagForTests(); + if (ORIGINAL_DISABLE === undefined) { + delete process.env.BOXEL_DISABLE_PATH_WARNING; + } else { + process.env.BOXEL_DISABLE_PATH_WARNING = ORIGINAL_DISABLE; + } + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('does not warn when no misplaced dirs are present', () => { + warnIfMisplacedLocalRealmDirs(tmpRoot); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('fires console.warn once even when called repeatedly', () => { + writeManifest(path.join(tmpRoot, 'misplaced'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + + warnIfMisplacedLocalRealmDirs(tmpRoot); + warnIfMisplacedLocalRealmDirs(tmpRoot); + + const callsWithLegacyHeader = warnSpy.mock.calls.filter((args) => + String(args[0] ?? '').includes('legacy local paths'), + ); + expect(callsWithLegacyHeader).toHaveLength(1); + }); + + it('does nothing when BOXEL_DISABLE_PATH_WARNING=1 is set', () => { + writeManifest(path.join(tmpRoot, 'misplaced'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + process.env.BOXEL_DISABLE_PATH_WARNING = '1'; + + warnIfMisplacedLocalRealmDirs(tmpRoot); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when isQuiet() is true', () => { + writeManifest(path.join(tmpRoot, 'misplaced'), { + realmUrl: 'https://stack.cards/alice/notes', + }); + setQuiet(true); + + warnIfMisplacedLocalRealmDirs(tmpRoot); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not throw when a manifest contains a malformed realmUrl (regression for preAction crash)', () => { + writeManifest(path.join(tmpRoot, 'broken'), { + realmUrl: 'not even a url', + }); + + expect(() => warnIfMisplacedLocalRealmDirs(tmpRoot)).not.toThrow(); + expect(warnSpy).not.toHaveBeenCalled(); + }); +});