import { Buffer } from 'node:buffer'; import { validateIntegrations, type PortableIntegrations } from './integrations.ts'; import { validatePackages, type PortablePackage } from './packages.ts'; import { validatePreferences, type PortablePreferences } from './preferences.ts'; import { validateKeybindings, type PortableKeybindings } from './keybindings.ts'; import { ProfileError, requireRecord, requireDataArray } from './validation.ts'; import { validateTransferReport, type TransferReport } from './transfer-report.ts'; export { ProfileError, type ProfileErrorCode } from './validation.ts'; // Bounds for one profile. They cover a whole installation in one export: one entry per selected live file, up // to PROFILE_LIMITS.resources entries and 24 MiB of decoded content. A single file may use the whole aggregate, // because skills can ship one large asset and splitting it gains nothing. The serialized JSON holds // that content (base64 for binary files), so jsonBytes stays above the aggregate, and the receiver validates // with the same numbers. export const PROFILE_LIMITS = Object.freeze({ jsonBytes: 48 * 1024 * 1024, depth: 8, resources: 1024, fileBytes: 24 * 1024 * 1024, totalBytes: 24 * 1024 * 1024, pathBytes: 240, segmentBytes: 100, }); const RESOURCE_KINDS = ['extension', 'skill', 'prompt', 'theme', 'agent'] as const; const SENSITIVE_FILES = new Set(['auth.json', 'trust.json', 'settings.json', 'keybindings.json', 'models.json', 'mcp.json', '.npmrc', 'credentials.json', 'credentials']); const SSH_KEY_NAME = /^id_(?:rsa|dsa|ecdsa|ed25519|xmss)(?:_sk)?(?:\.pub)?$/i; const SENSITIVE_DIRS = new Set(['sessions', 'history', 'logs', 'node_modules', '.ssh', '.aws', '.gnupg', 'credentials', 'secrets']); const OPERATIONAL_ROOTS = new Set(['cache', 'caches', 'runs', 'missions', 'automations']); export function assertPortableResourceRoot(root: string): void { if (typeof root !== 'string' || root.includes('\0') || root.split(/[\\/]/).some(segment => { const name = segment.toLowerCase(); return SENSITIVE_DIRS.has(name) || OPERATIONAL_ROOTS.has(name) || SENSITIVE_FILES.has(name) || SSH_KEY_NAME.test(name) || name === '.env' || name.startsWith('.env.'); })) throw new ProfileError('invalid-path', 'root'); } export type ResourceKind = (typeof RESOURCE_KINDS)[number]; export type ResourceEncoding = 'utf8' | 'base64'; export type ResourceEntrypoints = Partial>; export interface ProfileResource { kind: ResourceKind; path: string; encoding: ResourceEncoding; content: string; } export interface ResourceProfile { format: 'pi-setup-share'; version: 1 | 2; resources: ProfileResource[]; transfer?: TransferReport; preferences?: PortablePreferences; keybindings?: PortableKeybindings; integrations?: PortableIntegrations; packages?: PortablePackage[]; entrypoints?: ResourceEntrypoints; } function portablePath(value: unknown, field: string): asserts value is string { if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > PROFILE_LIMITS.pathBytes || value !== value.normalize('NFC') || /[\p{C}<>:"\\|?*]/u.test(value)) { throw new ProfileError('invalid-path', field); } const segments = value.split('/'); for (const segment of segments) { if (!segment || segment === '.' || segment === '..' || /^[ .]|[ .]$/.test(segment) || Buffer.byteLength(segment, 'utf8') > PROFILE_LIMITS.segmentBytes || /^(con|conin\$|conout\$|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(segment)) { throw new ProfileError('invalid-path', field); } } const names = segments.map(segment => segment.toLowerCase()); const filename = names.at(-1) as string; if (SENSITIVE_FILES.has(filename) || SSH_KEY_NAME.test(filename) || filename === '.env' || filename.startsWith('.env.') || /\.(?:log|jsonl)$/.test(filename) || names.slice(0, -1).some(segment => SENSITIVE_DIRS.has(segment)) || OPERATIONAL_ROOTS.has(names[0] as string)) { throw new ProfileError('invalid-path', field); } } function contentBytes(resource: ProfileResource, field: string): number { const { content, encoding } = resource; if (encoding === 'utf8') { const size = Buffer.byteLength(content, 'utf8'); if (size > PROFILE_LIMITS.fileBytes) throw new ProfileError('limit-exceeded', field); if (Buffer.from(content, 'utf8').toString('utf8') !== content) { throw new ProfileError('invalid-content', field); } return size; } if (content.length > 4 * Math.ceil(PROFILE_LIMITS.fileBytes / 3)) { throw new ProfileError('limit-exceeded', field); } // Buffer's decoder accepts whitespace and malformed padding; require a canonical round trip. const decoded = Buffer.from(content, 'base64'); if (decoded.toString('base64') !== content) throw new ProfileError('invalid-content', field); if (decoded.length > PROFILE_LIMITS.fileBytes) throw new ProfileError('limit-exceeded', field); return decoded.length; } function validateEntrypoints(value: unknown, resources: ProfileResource[]): ResourceEntrypoints { requireRecord(value, [], 'entrypoints', RESOURCE_KINDS); const result: ResourceEntrypoints = {}; for (const kind of RESOURCE_KINDS) { if (!Object.hasOwn(value, kind)) continue; const field = `entrypoints.${kind}`; const entries = value[kind]; requireDataArray(entries, PROFILE_LIMITS.resources, field); const seen = new Set(); result[kind] = entries.map(path => { portablePath(path, field); const resource = resources.find(entry => entry.kind === kind && entry.path === path); const supported = kind === 'extension' ? /\.(?:ts|js)$/.test(path) : kind === 'theme' ? path.endsWith('.json') : kind === 'skill' ? /(?:^|\/)SKILL\.md$/.test(path) : path.endsWith('.md') && !path.endsWith('.chain.md'); if (!resource || resource.encoding !== 'utf8' || !supported || seen.has(path)) throw new ProfileError('invalid-content', field); seen.add(path); return path; }); } return result; } export function validateProfile(value: unknown): ResourceProfile { requireRecord(value, ['format', 'version', 'resources'], 'profile', ['preferences', 'keybindings', 'integrations', 'packages', 'entrypoints', 'transfer']); if (value.format !== 'pi-setup-share') throw new ProfileError('invalid-shape', 'format'); if (value.version !== 1 && value.version !== 2) throw new ProfileError('unsupported-version', 'version'); if (Object.hasOwn(value, 'transfer') !== (value.version === 2)) throw new ProfileError('invalid-shape', 'transfer'); requireDataArray(value.resources, PROFILE_LIMITS.resources, 'resources'); const resources: ProfileResource[] = []; const paths = new Map(); let totalBytes = 0; for (let index = 0; index < value.resources.length; index++) { const entry: unknown = value.resources[index]; const field = `resources[${index}]`; requireRecord(entry, ['kind', 'path', 'encoding', 'content'], field); if (!RESOURCE_KINDS.some(kind => kind === entry.kind) || (entry.encoding !== 'utf8' && entry.encoding !== 'base64') || typeof entry.content !== 'string') { throw new ProfileError('invalid-shape', field); } portablePath(entry.path, `${field}.path`); const resource: ProfileResource = { kind: entry.kind as ResourceKind, path: entry.path, encoding: entry.encoding, content: entry.content, }; totalBytes += contentBytes(resource, `${field}.content`); if (totalBytes > PROFILE_LIMITS.totalBytes) throw new ProfileError('limit-exceeded', 'resources'); // Lowercase first so both ẞ and ß fold to SS. This is lexical, not filesystem equivalence. const key = `${resource.kind}/${resource.path}`.toLowerCase().toUpperCase().normalize('NFC'); if (paths.has(key)) throw new ProfileError('path-conflict', `${field}.path`); paths.set(key, index); resources.push(resource); } for (const [path, index] of paths) { const segments = path.split('/'); for (let length = 1; length < segments.length; length++) { if (paths.has(segments.slice(0, length).join('/'))) { throw new ProfileError('path-conflict', `resources[${index}].path`); } } } const profile: ResourceProfile = { format: 'pi-setup-share', version: value.version, resources }; if (value.version === 2) profile.transfer = validateTransferReport(value.transfer); if (Object.hasOwn(value, 'preferences')) profile.preferences = validatePreferences(value.preferences); if (Object.hasOwn(value, 'keybindings')) profile.keybindings = validateKeybindings(value.keybindings); if (Object.hasOwn(value, 'integrations')) profile.integrations = validateIntegrations(value.integrations); if (Object.hasOwn(value, 'packages')) profile.packages = validatePackages(value.packages); if (Object.hasOwn(value, 'entrypoints')) profile.entrypoints = validateEntrypoints(value.entrypoints, resources); if (profile.transfer) { const present = [ profile.preferences && 'preferences', profile.keybindings && 'keybindings', profile.integrations?.mcpServers && 'mcpServers', profile.integrations?.subagents && 'subagents', profile.packages && 'packages', ].filter((category): category is string => typeof category === 'string'); if (present.some(category => profile.transfer?.notExamined.includes(category as (typeof profile.transfer.notExamined)[number]))) { throw new ProfileError('invalid-content', 'transfer.notExamined'); } } return profile; } function checkJsonDepth(text: string): void { let depth = 0; let quoted = false; let escaped = false; for (const character of text) { if (quoted) { if (escaped) escaped = false; else if (character === '\\') escaped = true; else if (character === '"') quoted = false; } else if (character === '"') quoted = true; else if (character === '{' || character === '[') { if (++depth > PROFILE_LIMITS.depth) throw new ProfileError('limit-exceeded', 'profile'); } else if (character === '}' || character === ']') depth--; } } export function parseProfile(text: string): ResourceProfile { if (Buffer.byteLength(text, 'utf8') > PROFILE_LIMITS.jsonBytes) { throw new ProfileError('limit-exceeded', 'profile'); } checkJsonDepth(text); let value: unknown; try { value = JSON.parse(text); } catch { throw new ProfileError('invalid-json', 'profile'); } return validateProfile(value); }