258 lines
9.5 KiB
TypeScript
258 lines
9.5 KiB
TypeScript
import {
|
|
chmodSync,
|
|
closeSync,
|
|
fstatSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readlinkSync,
|
|
unlinkSync,
|
|
writeSync,
|
|
} from "node:fs";
|
|
import { hostname, homedir } from "node:os";
|
|
import { isAbsolute, join, normalize, relative } from "node:path";
|
|
import { validateBrowserProfileName } from "./config.ts";
|
|
const PROFILE_ROOT_MODE = 0o700;
|
|
const PROFILE_LOCK_MODE = 0o600;
|
|
|
|
export interface ProfileSingletonLockOwnership {
|
|
path: string;
|
|
target: string;
|
|
pid: number;
|
|
processStartTime?: string;
|
|
device: number;
|
|
inode: number;
|
|
}
|
|
|
|
export interface ProfileLease {
|
|
name: string;
|
|
profileDirectory: string;
|
|
lockPath: string;
|
|
fd?: number;
|
|
device: number;
|
|
inode: number;
|
|
/** Set only after the launched process proves the lock belongs to it. */
|
|
singletonLock?: ProfileSingletonLockOwnership;
|
|
}
|
|
|
|
export interface ProfileLeaseOptions {
|
|
/** Test seam for the managed profile root. */
|
|
profileRoot?: string;
|
|
}
|
|
|
|
export const validateProfileName = validateBrowserProfileName;
|
|
|
|
export function profileStorageRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
const dataHome = env.XDG_DATA_HOME?.trim();
|
|
if (dataHome && !isAbsolute(dataHome)) {
|
|
throw new Error("XDG_DATA_HOME must be an absolute path for named browser profiles");
|
|
}
|
|
return join(dataHome || join(env.HOME?.trim() || homedir(), ".local", "share"), "fast-browser", "profiles");
|
|
}
|
|
|
|
function assertContained(root: string, candidate: string): void {
|
|
const rootPath = normalize(root);
|
|
const candidatePath = normalize(candidate);
|
|
const remainder = relative(rootPath, candidatePath);
|
|
if (!remainder || remainder.startsWith("..") || isAbsolute(remainder)) {
|
|
throw new Error("browser profile path escaped the managed profile root");
|
|
}
|
|
}
|
|
|
|
function ensurePrivateDirectory(path: string, label: string): void {
|
|
mkdirSync(path, { recursive: true, mode: PROFILE_ROOT_MODE });
|
|
const before = lstatSync(path);
|
|
if (!before.isDirectory() || before.isSymbolicLink()) throw new Error(`${label} must be a real directory`);
|
|
if (typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
throw new Error(`${label} is not owned by this user`);
|
|
}
|
|
chmodSync(path, PROFILE_ROOT_MODE);
|
|
const after = lstatSync(path);
|
|
if (!after.isDirectory() || after.isSymbolicLink() || (after.mode & 0o077) !== 0) {
|
|
throw new Error(`${label} must be private (0700)`);
|
|
}
|
|
}
|
|
|
|
export function profileDirectory(name: string, options: ProfileLeaseOptions = {}): string {
|
|
const safeName = validateProfileName(name);
|
|
const root = options.profileRoot ?? profileStorageRoot();
|
|
ensurePrivateDirectory(root, "browser profile root");
|
|
const directory = join(root, safeName);
|
|
assertContained(root, directory);
|
|
if (!lstatDirectoryOrMissing(directory)) throw new Error("browser profile must be a real directory, not a symlink");
|
|
mkdirSync(directory, { recursive: true, mode: PROFILE_ROOT_MODE });
|
|
ensurePrivateDirectory(directory, "browser profile");
|
|
return directory;
|
|
}
|
|
|
|
function lstatDirectoryOrMissing(path: string): boolean {
|
|
try {
|
|
const stats = lstatSync(path);
|
|
return stats.isDirectory() && !stats.isSymbolicLink();
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return true;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function lockPathFor(root: string, name: string): string {
|
|
const path = join(root, `.${name}.fast-browser-profile.lock`);
|
|
assertContained(root, path);
|
|
return path;
|
|
}
|
|
|
|
function activeSingletonLock(directory: string): string | undefined {
|
|
const path = join(directory, "SingletonLock");
|
|
try {
|
|
const stats = lstatSync(path);
|
|
if (stats.isSymbolicLink() || stats.isFile()) return path;
|
|
throw new Error("browser profile SingletonLock is not a regular lock entry");
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function acquireProfileLease(name: string, options: ProfileLeaseOptions = {}): ProfileLease {
|
|
const safeName = validateProfileName(name);
|
|
const root = options.profileRoot ?? profileStorageRoot();
|
|
ensurePrivateDirectory(root, "browser profile root");
|
|
const directory = profileDirectory(safeName, { profileRoot: root });
|
|
const lockPath = lockPathFor(root, safeName);
|
|
let fd: number;
|
|
try {
|
|
fd = openSync(lockPath, "wx", PROFILE_LOCK_MODE);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "EEXIST") {
|
|
throw new Error(`browser profile ${safeName} already has an active or stale lease at ${lockPath}; verify the owning process is gone and remove the lease manually before retrying`);
|
|
}
|
|
throw new Error(`unable to acquire browser profile lease for ${safeName}`);
|
|
}
|
|
try {
|
|
const singleton = activeSingletonLock(directory);
|
|
if (singleton) {
|
|
throw new Error(`browser profile ${safeName} has SingletonLock at ${singleton}; verify Chromium is not running and remove it manually only after inspection`);
|
|
}
|
|
const metadata = JSON.stringify({ pid: process.pid, profile: safeName }) + "\n";
|
|
writeSync(fd, metadata, undefined, "utf8");
|
|
const stats = fstatSync(fd);
|
|
return {
|
|
name: safeName,
|
|
profileDirectory: directory,
|
|
lockPath,
|
|
fd,
|
|
device: stats.dev,
|
|
inode: stats.ino,
|
|
};
|
|
} catch (error) {
|
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
try { unlinkSync(lockPath); } catch { /* this process owns it; preserve unexpected replacement */ }
|
|
throw error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
}
|
|
|
|
export function releaseProfileLease(lease: ProfileLease): void {
|
|
let failure: Error | undefined;
|
|
try {
|
|
if (activeSingletonLock(lease.profileDirectory)) {
|
|
throw new Error(`browser profile SingletonLock remains at ${join(lease.profileDirectory, "SingletonLock")}; refusing to release the profile lease`);
|
|
}
|
|
const stats = lstatSync(lease.lockPath);
|
|
if (stats.dev !== lease.device || stats.ino !== lease.inode) {
|
|
throw new Error(`browser profile lease changed unexpectedly; refusing to remove ${lease.lockPath}`);
|
|
}
|
|
unlinkSync(lease.lockPath);
|
|
} catch (error) {
|
|
failure = error instanceof Error ? error : new Error(String(error));
|
|
} finally {
|
|
if (lease.fd !== undefined) {
|
|
const fd = lease.fd;
|
|
lease.fd = undefined;
|
|
try { closeSync(fd); } catch (error) {
|
|
if (!failure) failure = error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
}
|
|
}
|
|
if (failure) throw failure;
|
|
}
|
|
|
|
function singletonLockDetails(lease: ProfileLease): ProfileSingletonLockOwnership | undefined {
|
|
const path = join(lease.profileDirectory, "SingletonLock");
|
|
let stats;
|
|
try { stats = lstatSync(path); }
|
|
catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
if (!stats.isSymbolicLink()) {
|
|
throw new Error(`browser profile SingletonLock is not a symlink; refusing to claim ownership of ${path}`);
|
|
}
|
|
const target = readlinkSync(path);
|
|
const separator = target.lastIndexOf("-");
|
|
const host = separator > 0 ? target.slice(0, separator) : "";
|
|
const pid = separator > 0 ? Number(target.slice(separator + 1)) : NaN;
|
|
if (host !== hostname() || !Number.isSafeInteger(pid) || pid < 1) {
|
|
throw new Error(`browser profile SingletonLock ownership is not provable for ${path}`);
|
|
}
|
|
return { path, target, pid, device: stats.dev, inode: stats.ino };
|
|
}
|
|
|
|
/**
|
|
* Record the Chromium SingletonLock only when it names the process we spawned.
|
|
* A missing marker is normal during early startup; an unexpected marker is
|
|
* retained as an ownership failure rather than guessed to be stale.
|
|
*/
|
|
export function captureOwnedSingletonLock(
|
|
lease: ProfileLease,
|
|
pid: number | undefined,
|
|
processStartTime?: string,
|
|
): void {
|
|
if (pid === undefined) return;
|
|
const details = singletonLockDetails(lease);
|
|
if (!details) return;
|
|
if (details.pid !== pid) {
|
|
throw new Error(`browser profile SingletonLock names pid ${details.pid}, not owned browser pid ${pid}`);
|
|
}
|
|
lease.singletonLock = processStartTime === undefined
|
|
? details
|
|
: { ...details, processStartTime };
|
|
}
|
|
|
|
/**
|
|
* Remove only the exact marker captured from our launched process, and only
|
|
* after the caller has independently proved that that process is gone.
|
|
*/
|
|
export function removeOwnedSingletonLock(
|
|
lease: ProfileLease,
|
|
processGone: (ownership: ProfileSingletonLockOwnership) => boolean,
|
|
): void {
|
|
const current = singletonLockDetails(lease);
|
|
if (!current) return;
|
|
const owned = lease.singletonLock;
|
|
if (!owned || owned.path !== current.path || owned.target !== current.target || owned.device !== current.device || owned.inode !== current.inode) {
|
|
throw new Error(`browser profile SingletonLock ownership is uncertain at ${current.path}; retaining the profile lease`);
|
|
}
|
|
if (!processGone(owned)) {
|
|
throw new Error(`browser profile SingletonLock owner has not been proven gone at ${current.path}; retaining the profile lease`);
|
|
}
|
|
unlinkSync(current.path);
|
|
lease.singletonLock = undefined;
|
|
}
|
|
|
|
/** Remove stale CDP metadata only while an exclusive named-profile lease is held. */
|
|
export function removeStaleDevToolsActivePort(lease: ProfileLease): void {
|
|
const path = join(lease.profileDirectory, "DevToolsActivePort");
|
|
const stats = (() => {
|
|
try { return lstatSync(path); }
|
|
catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
})();
|
|
if (!stats) return;
|
|
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
throw new Error(`browser profile DevToolsActivePort is not a regular file; refusing to remove ${path}`);
|
|
}
|
|
unlinkSync(path);
|
|
}
|