mirror of https://github.com/garrytan/gstack.git
209 lines
8.7 KiB
TypeScript
209 lines
8.7 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Retire the pre-#1324 unpacked gstack browser extension from GStack's
|
|
* dedicated Chromium profile.
|
|
*
|
|
* The old extension's ID was derived from its installation path, so it cannot
|
|
* be a trust anchor. The current extension has a manifest key and a fixed ID.
|
|
* This tool only edits ~/.gstack/chromium-profile (or an explicit test/profile
|
|
* directory); it never touches a user's normal Chrome profile.
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { createHash } from 'crypto';
|
|
|
|
const TRUSTED_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk';
|
|
const EXTENSION_NAME = 'gstack browse';
|
|
|
|
type Result = {
|
|
profile: string;
|
|
status: 'clean' | 'migrated' | 'deferred';
|
|
legacyExtensionIds: string[];
|
|
backup?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
function usage(): never {
|
|
console.error('Usage: gstack-browse-migrate [--check|--apply] [--profile <gstack-profile>] [--legacy-extension-path <path>] [--json]');
|
|
process.exit(2);
|
|
}
|
|
|
|
function parseArgs(argv: string[]) {
|
|
let mode: 'check' | 'apply' = 'check';
|
|
let profile = process.env.CHROMIUM_PROFILE
|
|
|| path.join(process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'), 'chromium-profile');
|
|
let json = false;
|
|
const legacyExtensionPaths: string[] = [];
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--check') mode = 'check';
|
|
else if (argv[i] === '--apply') mode = 'apply';
|
|
else if (argv[i] === '--json') json = true;
|
|
else if (argv[i] === '--profile') {
|
|
profile = argv[++i] || usage();
|
|
} else if (argv[i] === '--legacy-extension-path') {
|
|
legacyExtensionPaths.push(argv[++i] || usage());
|
|
} else usage();
|
|
}
|
|
return { mode, profile: path.resolve(profile), json, legacyExtensionPaths };
|
|
}
|
|
|
|
function writeAtomic(file: string, content: string): void {
|
|
const tmp = `${file}.gstack-migrate-${process.pid}.tmp`;
|
|
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
|
|
function profileDirectories(profileRoot: string): string[] {
|
|
if (!fs.existsSync(profileRoot)) return [];
|
|
return fs.readdirSync(profileRoot, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory() && (entry.name === 'Default' || entry.name.startsWith('Profile ')))
|
|
.map(entry => path.join(profileRoot, entry.name));
|
|
}
|
|
|
|
function findLegacyIds(preferences: any): string[] {
|
|
const settings = preferences?.extensions?.settings;
|
|
if (!settings || typeof settings !== 'object') return [];
|
|
return Object.entries(settings)
|
|
.filter(([id, value]: [string, any]) => id !== TRUSTED_EXTENSION_ID && value?.manifest?.name === EXTENSION_NAME)
|
|
.map(([id]) => id);
|
|
}
|
|
|
|
/**
|
|
* Before manifest.key, Chromium generated an unpacked extension's ID from the
|
|
* absolute extension directory. The current manifest has a fixed key, but the
|
|
* old ID can still be derived from that same path during an upgrade.
|
|
*/
|
|
function legacyIdForExtensionPath(extensionPath: string): string[] {
|
|
const candidates = new Set([path.resolve(extensionPath)]);
|
|
try { candidates.add(fs.realpathSync(extensionPath)); } catch {}
|
|
return [...candidates].map(candidate => [...createHash('sha256').update(candidate).digest('hex').slice(0, 32)]
|
|
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16)))
|
|
.join(''));
|
|
}
|
|
|
|
function storageLegacyIds(profileDir: string, extensionPaths: string[]): string[] {
|
|
const storageRoots = ['Local Extension Settings', 'Sync Extension Settings'];
|
|
const storedIds = new Set<string>();
|
|
for (const storageRoot of storageRoots) {
|
|
const root = path.join(profileDir, storageRoot);
|
|
if (!fs.existsSync(root)) continue;
|
|
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) storedIds.add(entry.name);
|
|
}
|
|
}
|
|
const knownLegacyIds = extensionPaths.flatMap(legacyIdForExtensionPath);
|
|
return knownLegacyIds.filter(id => id !== TRUSTED_EXTENSION_ID && storedIds.has(id));
|
|
}
|
|
|
|
function output(result: Result, asJson: boolean): void {
|
|
if (asJson) console.log(JSON.stringify(result));
|
|
else {
|
|
console.log(`[browse-migrate] ${result.status}: ${result.profile}`);
|
|
if (result.legacyExtensionIds.length) console.log(`[browse-migrate] legacy extension IDs: ${result.legacyExtensionIds.join(', ')}`);
|
|
if (result.backup) console.log(`[browse-migrate] backup: ${result.backup}`);
|
|
if (result.reason) console.log(`[browse-migrate] ${result.reason}`);
|
|
}
|
|
}
|
|
|
|
/** Chromium normally represents SingletonLock as a dangling hostname-PID symlink. */
|
|
function profileLockIsActive(lock: string): boolean {
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.lstatSync(lock);
|
|
} catch (err: any) {
|
|
if (err?.code === 'ENOENT') return false;
|
|
// If lock state cannot be inspected, do not risk editing the profile.
|
|
return true;
|
|
}
|
|
if (!stat.isSymbolicLink()) return true;
|
|
|
|
try {
|
|
const target = fs.readlinkSync(lock);
|
|
const match = target.match(/^(.*)-(\d+)$/);
|
|
if (!match || match[1] !== os.hostname()) return true;
|
|
const pid = Number(match[2]);
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) return true;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err: any) {
|
|
return err?.code !== 'ESRCH';
|
|
}
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function migrateProfile(profileRoot: string, mode: 'check' | 'apply', extensionPaths: string[]): Result[] {
|
|
const lock = path.join(profileRoot, 'SingletonLock');
|
|
if (profileLockIsActive(lock)) {
|
|
return [{ profile: profileRoot, status: 'deferred', legacyExtensionIds: [], reason: 'browser profile is active; close GStack Browser and retry' }];
|
|
}
|
|
|
|
const results: Result[] = [];
|
|
for (const profileDir of profileDirectories(profileRoot)) {
|
|
const preferencesPath = path.join(profileDir, 'Preferences');
|
|
const hasPreferences = fs.existsSync(preferencesPath);
|
|
let preferences: any = { extensions: { settings: {} } };
|
|
if (hasPreferences) {
|
|
try {
|
|
preferences = JSON.parse(fs.readFileSync(preferencesPath, 'utf8'));
|
|
} catch {
|
|
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: [], reason: 'Preferences is not valid JSON; left unchanged' });
|
|
continue;
|
|
}
|
|
}
|
|
// Chrome may omit an unpacked extension from Preferences. In that case,
|
|
// derive the old ID from the upgraded extension's path and require a
|
|
// matching storage directory before acting. This remains narrowly scoped:
|
|
// unrelated extension IDs are never selected by a broad directory scan.
|
|
const legacyIds = [...new Set([
|
|
...findLegacyIds(preferences),
|
|
...storageLegacyIds(profileDir, extensionPaths),
|
|
])];
|
|
if (!legacyIds.length) {
|
|
results.push({ profile: profileDir, status: 'clean', legacyExtensionIds: [] });
|
|
continue;
|
|
}
|
|
if (mode === 'check') {
|
|
results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: legacyIds, reason: 'run with --apply to retire legacy extension state' });
|
|
continue;
|
|
}
|
|
|
|
const migrationRoot = path.join(profileRoot, '..', 'browser-migrations', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
const backupDir = path.join(migrationRoot, path.basename(profileDir));
|
|
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
if (hasPreferences) fs.copyFileSync(preferencesPath, path.join(backupDir, 'Preferences'));
|
|
|
|
if (hasPreferences) {
|
|
for (const id of legacyIds) delete preferences.extensions?.settings?.[id];
|
|
writeAtomic(preferencesPath, `${JSON.stringify(preferences, null, 2)}\n`);
|
|
}
|
|
|
|
// Extension storage is the only place the old design could retain its
|
|
// daemon credential. Move it aside instead of deleting it so rollback is
|
|
// possible without ever copying credentials into the new extension ID.
|
|
for (const storageRoot of ['Local Extension Settings', 'Sync Extension Settings']) {
|
|
for (const id of legacyIds) {
|
|
const source = path.join(profileDir, storageRoot, id);
|
|
if (!fs.existsSync(source)) continue;
|
|
const target = path.join(backupDir, storageRoot, id);
|
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
fs.renameSync(source, target);
|
|
}
|
|
}
|
|
results.push({ profile: profileDir, status: 'migrated', legacyExtensionIds: legacyIds, backup: backupDir });
|
|
}
|
|
return results.length ? results : [{ profile: profileRoot, status: 'clean', legacyExtensionIds: [] }];
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const results = migrateProfile(args.profile, args.mode, args.legacyExtensionPaths);
|
|
for (const result of results) output(result, args.json);
|
|
// Check mode uses "deferred" to report work without failing. Apply mode must
|
|
// fail for every deferred profile so the versioned updater never writes its
|
|
// done marker while legacy credential state remains untouched.
|
|
process.exit(args.mode === 'apply' && results.some(result => result.status === 'deferred') ? 3 : 0);
|