207 lines
8.7 KiB
TypeScript
207 lines
8.7 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import { existsSync, realpathSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
|
|
export type FileCapabilityPurpose =
|
|
| 'cutter-input'
|
|
| 'cutter-output'
|
|
| 'merge-input'
|
|
| 'merge-output'
|
|
| 'chat-input'
|
|
| 'config-import'
|
|
| 'config-export'
|
|
| 'runtime-export'
|
|
| 'selected-folder'
|
|
| 'open-file'
|
|
| 'show-in-folder';
|
|
|
|
export type FileCapabilityKind = 'input-file' | 'output-file' | 'directory';
|
|
|
|
export interface FileCapabilityReference {
|
|
token: string;
|
|
name: string;
|
|
}
|
|
|
|
interface FileIdentity {
|
|
dev: number;
|
|
ino: number;
|
|
size: number;
|
|
mtimeMs: number;
|
|
}
|
|
|
|
interface FileCapabilityGrant {
|
|
ownerId: number;
|
|
purpose: FileCapabilityPurpose;
|
|
path: string;
|
|
kind: FileCapabilityKind;
|
|
extensions: Set<string>;
|
|
expiresAt: number;
|
|
identity: FileIdentity | null;
|
|
}
|
|
|
|
interface IssueFileCapabilityOptions {
|
|
ownerId: number;
|
|
purpose: FileCapabilityPurpose;
|
|
path: string;
|
|
kind: FileCapabilityKind;
|
|
extensions?: string[];
|
|
ttlMs?: number;
|
|
}
|
|
|
|
interface FileCapabilityStoreOptions {
|
|
now?: () => number;
|
|
defaultTtlMs?: number;
|
|
}
|
|
|
|
function normalizeExtension(extension: string): string {
|
|
const normalized = extension.trim().toLowerCase();
|
|
return normalized.startsWith('.') ? normalized : `.${normalized}`;
|
|
}
|
|
|
|
function comparablePath(filePath: string): string {
|
|
return process.platform === 'win32' ? filePath.toLocaleLowerCase('en-US') : filePath;
|
|
}
|
|
|
|
function canonicalInputPath(filePath: string, kind: FileCapabilityKind): string {
|
|
if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute');
|
|
if (!existsSync(filePath)) throw new Error(kind === 'directory' ? 'Directory does not exist' : 'Input file does not exist');
|
|
const canonical = realpathSync.native(resolve(filePath));
|
|
const stats = statSync(canonical);
|
|
if (kind === 'directory' && !stats.isDirectory()) throw new Error('Capability path is not a directory');
|
|
if (kind === 'input-file' && !stats.isFile()) throw new Error('Capability path is not a file');
|
|
return canonical;
|
|
}
|
|
|
|
function canonicalOutputPath(filePath: string): string {
|
|
if (typeof filePath !== 'string' || !filePath || !isAbsolute(filePath)) throw new Error('File path must be absolute');
|
|
const resolved = resolve(filePath);
|
|
const parent = dirname(resolved);
|
|
if (!existsSync(parent) || !statSync(parent).isDirectory()) throw new Error('Output directory does not exist');
|
|
if (existsSync(resolved) && !statSync(resolved).isFile()) throw new Error('Output target is not a file');
|
|
return join(realpathSync.native(parent), basename(resolved));
|
|
}
|
|
|
|
function validateExtension(filePath: string, extensions: Set<string>): void {
|
|
const lowerPath = filePath.toLowerCase();
|
|
if (extensions.size > 0 && !Array.from(extensions).some((extension) => lowerPath.endsWith(extension))) throw new Error('File extension is not allowed');
|
|
}
|
|
|
|
function getIdentity(filePath: string): FileIdentity {
|
|
const stats = statSync(filePath);
|
|
return { dev: stats.dev, ino: stats.ino, size: stats.size, mtimeMs: stats.mtimeMs };
|
|
}
|
|
|
|
function identitiesMatch(left: FileIdentity, right: FileIdentity): boolean {
|
|
if (left.dev !== right.dev || left.size !== right.size || left.mtimeMs !== right.mtimeMs) return false;
|
|
return left.ino === 0 || right.ino === 0 || left.ino === right.ino;
|
|
}
|
|
|
|
export function isTrustedFileIpcSender(expectedOwnerId: number, expectedUrl: string, actualOwnerId: number, actualUrl: string): boolean {
|
|
if (actualOwnerId !== expectedOwnerId || typeof actualUrl !== 'string') return false;
|
|
return actualUrl.split(/[?#]/, 1)[0] === expectedUrl;
|
|
}
|
|
|
|
export class FileCapabilityStore {
|
|
private readonly grants = new Map<string, FileCapabilityGrant>();
|
|
private readonly now: () => number;
|
|
private readonly defaultTtlMs: number;
|
|
|
|
constructor(options: FileCapabilityStoreOptions = {}) {
|
|
this.now = options.now ?? Date.now;
|
|
this.defaultTtlMs = options.defaultTtlMs ?? 15 * 60 * 1000;
|
|
}
|
|
|
|
issue(options: IssueFileCapabilityOptions): FileCapabilityReference {
|
|
const extensions = new Set((options.extensions ?? []).map(normalizeExtension));
|
|
const canonical = options.kind === 'output-file'
|
|
? canonicalOutputPath(options.path)
|
|
: canonicalInputPath(options.path, options.kind);
|
|
validateExtension(canonical, extensions);
|
|
const ttlMs = options.ttlMs ?? this.defaultTtlMs;
|
|
if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('File capability lifetime is invalid');
|
|
const token = randomBytes(32).toString('base64url');
|
|
this.grants.set(token, {
|
|
ownerId: options.ownerId,
|
|
purpose: options.purpose,
|
|
path: canonical,
|
|
kind: options.kind,
|
|
extensions,
|
|
expiresAt: this.now() + ttlMs,
|
|
identity: options.kind === 'input-file' ? getIdentity(canonical) : null,
|
|
});
|
|
return { token, name: basename(canonical) };
|
|
}
|
|
|
|
resolve(token: string, ownerId: number, purpose: FileCapabilityPurpose): string {
|
|
return this.resolveGrant(token, ownerId, purpose, false);
|
|
}
|
|
|
|
consume(token: string, ownerId: number, purpose: FileCapabilityPurpose, protectedPaths: string[] = []): string {
|
|
const resolved = this.resolveGrant(token, ownerId, purpose, false);
|
|
for (const protectedPath of protectedPaths) {
|
|
const canonicalProtected = existsSync(protectedPath)
|
|
? realpathSync.native(resolve(protectedPath))
|
|
: canonicalOutputPath(protectedPath);
|
|
const samePath = comparablePath(resolved) === comparablePath(canonicalProtected);
|
|
const sameFile = existsSync(resolved)
|
|
&& existsSync(canonicalProtected)
|
|
&& identitiesMatch(getIdentity(resolved), getIdentity(canonicalProtected));
|
|
if (samePath || sameFile) throw new Error('Output path conflicts with a protected input');
|
|
}
|
|
this.grants.delete(token);
|
|
return resolved;
|
|
}
|
|
|
|
revoke(token: string): void {
|
|
this.grants.delete(token);
|
|
}
|
|
|
|
private resolveGrant(token: string, ownerId: number, purpose: FileCapabilityPurpose, consume: boolean): string {
|
|
if (typeof token !== 'string' || !token) throw new Error('Invalid file capability');
|
|
const grant = this.grants.get(token);
|
|
if (!grant) throw new Error('Invalid file capability');
|
|
if (grant.ownerId !== ownerId) throw new Error('Invalid file capability owner');
|
|
if (grant.purpose !== purpose) throw new Error('Invalid file capability purpose');
|
|
if (this.now() >= grant.expiresAt) {
|
|
this.grants.delete(token);
|
|
throw new Error('Expired file capability');
|
|
}
|
|
const currentPath = grant.kind === 'output-file'
|
|
? canonicalOutputPath(grant.path)
|
|
: canonicalInputPath(grant.path, grant.kind);
|
|
validateExtension(currentPath, grant.extensions);
|
|
if (comparablePath(currentPath) !== comparablePath(grant.path)) throw new Error('File capability path changed');
|
|
if (grant.identity && !identitiesMatch(grant.identity, getIdentity(currentPath))) throw new Error('File capability path changed');
|
|
if (consume) this.grants.delete(token);
|
|
return currentPath;
|
|
}
|
|
}
|
|
|
|
export async function publishCapabilityOutput(outputPath: string, produce: (partialPath: string) => Promise<boolean>): Promise<boolean> {
|
|
const canonicalOutput = canonicalOutputPath(outputPath);
|
|
const extension = extname(canonicalOutput);
|
|
const stem = basename(canonicalOutput, extension);
|
|
const partialPath = join(dirname(canonicalOutput), `.${stem}.${process.pid}.${randomBytes(8).toString('hex')}.partial${extension}`);
|
|
const backupPath = `${canonicalOutput}.${process.pid}.${randomBytes(8).toString('hex')}.backup`;
|
|
let backupCreated = false;
|
|
try {
|
|
const produced = await produce(partialPath);
|
|
if (!produced || !existsSync(partialPath) || !statSync(partialPath).isFile()) return false;
|
|
if (existsSync(canonicalOutput)) {
|
|
renameSync(canonicalOutput, backupPath);
|
|
backupCreated = true;
|
|
}
|
|
try {
|
|
renameSync(partialPath, canonicalOutput);
|
|
} catch (error) {
|
|
if (backupCreated && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput);
|
|
throw error;
|
|
}
|
|
if (backupCreated) rmSync(backupPath, { force: true });
|
|
return true;
|
|
} finally {
|
|
rmSync(partialPath, { force: true });
|
|
if (backupCreated && existsSync(backupPath) && !existsSync(canonicalOutput)) renameSync(backupPath, canonicalOutput);
|
|
}
|
|
}
|