Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/boxel-cli/src/build-program.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -30,6 +32,7 @@ export function buildBoxelProgram(version: string): Command {
if (opts.quiet) {
setQuiet(true);
}
warnIfMisplacedLocalRealmDirs(process.cwd());
});

program
Expand Down Expand Up @@ -86,6 +89,7 @@ Environment variables (for 'add'):
registerRunCommand(program);
registerSearchCommand(program);
registerReadTranspiledCommand(program);
registerConsolidateWorkspacesCommand(program);

return program;
}
104 changes: 104 additions & 0 deletions packages/boxel-cli/src/commands/consolidate-workspaces.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <root>/<domain>/<owner>/<realm> 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);
},
);
}
243 changes: 243 additions & 0 deletions packages/boxel-cli/src/lib/realm-local-paths.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +74 to +82
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in bf38b9b with defence in depth:

  1. isSafePathSegment rejects any segment whose decoded form contains /, \, or NUL, or is exactly . or .. — applied to the domain, owner, and realm segments.
  2. findMisplacedLocalRealmDirs has a new isWithin(absoluteRoot, expectedDir) containment check that drops any resolved destination not strictly inside rootDir.

Note: WHATWG URL parsing already normalizes unencoded .. traversal (e.g. https://stack.cards/../../etc/passwd resolves to /etc/passwd under stack.cards), so the practical attack surface is segments with percent-encoded separators like %2F, %5C, %00. Tests cover both branches: encoded-separator segments → null; traversal-shaped URLs → result stays inside rootDir.


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<string, unknown>).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: <root>/<realm>/.boxel-sync.json
for (const childDir of listSubdirs(absoluteRoot)) {
addManifestIfExists(childDir, manifests);
}

// Canonical layout: <root>/<domain>/<owner>/<realm>/.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<string>();
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;
}
Loading