fix(extensions): preserve trust across native path aliases and publisher input

This commit is contained in:
Forge
2026-09-22 04:47:58 +00:00
parent bae8e16dde
commit 258cd005fe
8 changed files with 62 additions and 9 deletions
+3
View File
@@ -154,6 +154,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- Managed extensions remain trusted when a project is opened with different path capitalization on Windows and macOS.
- Publisher forms preserve entered descriptions and source links while package metadata finishes loading.
- Interrupted extension installs and changes recover their last consistent configuration and graph, while conflicting user edits produce repair instructions instead of being overwritten.
- Rust calls on `self` now stay with the enclosing type instead of linking to an unrelated type’s same-named method. Thanks @L4XB. (#1861)
+23
View File
@@ -0,0 +1,23 @@
import { afterEach, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { trustedPackageDigest } from '../src/plugins/package';
const roots: string[] = [];
afterEach(() => roots.splice(0).forEach(p => fs.rmSync(p, { recursive: true, force: true })));
it('matches the same directory through a real filesystem alias without trusting a different directory', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-trust-')); roots.push(root);
const directory = path.join(root, 'Package'), alias = path.join(root, 'Alias'), other = path.join(root, 'Other');
fs.mkdirSync(directory); fs.mkdirSync(other);
fs.symlinkSync(directory, alias, process.platform === 'win32' ? 'junction' : 'dir');
expect(trustedPackageDigest(directory, { [alias]: 'approved' })).toBe('approved');
expect(trustedPackageDigest(other, { [alias]: 'approved' })).toBeUndefined();
expect(trustedPackageDigest(directory, { [other]: 'approved' })).toBeUndefined();
});
it('refuses conflicting alias approvals instead of guessing one', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-trust-')); roots.push(root);
const directory = path.join(root, 'Package'), a = path.join(root, 'AliasA'), b = path.join(root, 'AliasB');
fs.mkdirSync(directory);
for (const alias of [a,b]) fs.symlinkSync(directory, alias, process.platform === 'win32' ? 'junction' : 'dir');
expect(() => trustedPackageDigest(directory, { [a]: 'first', [b]: 'second' })).toThrow(/Conflicting trust/);
});
+6 -3
View File
@@ -175,11 +175,14 @@ document.addEventListener('change',async event=>{
if(event.target.id==='project'){state.project=event.target.value;void refreshSelections();}
if(event.target.id==='artifact')try{
const file=event.target.files[0];if(file.size>8*1024*1024)throw new Error('Package exceeds 8 MiB');
const input=event.target, form=input.closest('form');
const fields=Object.fromEntries(['name','description','source'].map(id=>[id,form.querySelector('#'+id)]));
const before=Object.fromEntries(Object.entries(fields).map(([id,field])=>[id,field.value]));
const pkg=JSON.parse(await file.text()).package;
if(!form.isConnected||input.files[0]!==file)return;
document.querySelector('#package-summary').textContent=`${pkg.codegraph.id} · v${pkg.version} · API ${pkg.codegraph.apiVersion}`;
document.querySelector('#name').value=pkg.codegraph.id;
document.querySelector('#description').value=pkg.description||'';
document.querySelector('#source').value=typeof pkg.repository==='string'?pkg.repository:'';
const defaults={name:pkg.codegraph.id,description:pkg.description||'',source:typeof pkg.repository==='string'?pkg.repository:''};
for(const [id,value] of Object.entries(defaults))if(before[id]===''&&fields[id].value===before[id])fields[id].value=value;
}catch(e){toast(e.message);}
if(event.target.id==='restore-key')try{const key=JSON.parse(await event.target.files[0].text());await crypto.subtle.importKey('jwk',key,{name:'ECDSA',namedCurve:'P-256'},true,['sign']);await keyStore('put',key);toast('Publisher key restored.');}catch{toast('Invalid publisher key.');}
});
+6 -1
View File
@@ -45,6 +45,8 @@ function routes(root) {
debugPage = page;
const pageErrors = []; page.on('pageerror', e => pageErrors.push(e.message));
await page.goto(bridge.connectionUrl, { waitUntil: 'domcontentloaded' });
// Reproduce slow native file reads: metadata must never erase typed fields.
await page.evaluate(() => { const original=File.prototype.text; File.prototype.text=async function(){await new Promise(resolve=>setTimeout(resolve,500));return original.call(this);}; });
console.log('Opened marketplace');
await page.getByRole('link', { name: 'Publish an extension' }).click();
async function publish(version, broken = false) {
@@ -56,12 +58,15 @@ function routes(root) {
await page.locator('#description').fill('A second framework used for end-to-end validation.');
await page.locator('#source').fill('https://example.com/recovery-source');
await page.locator('#readme').fill('Validation-only extension. Adds one explicit route to each TypeScript file.');
await page.locator('#package-summary').filter({ hasText: 'v'+version+' ·' }).waitFor();
assert.equal(await page.locator('#description').inputValue(), 'A second framework used for end-to-end validation.');
assert.equal(await page.locator('#source').inputValue(), 'https://example.com/recovery-source');
await page.getByRole('button', { name: 'Publish release' }).click();
console.log('Submitted', version);
await page.waitForURL('**/extensions/browser-example');
await page.getByText(version, { exact: true }).waitFor();
}
await publish('1.0.0'); checks.push('real browser signed publisher upload');
await publish('1.0.0'); checks.push('real browser signed publisher upload'); checks.push('slow package metadata preserves typed publisher fields');
const first = (await (await fetch(origin + '/api/extensions')).json())[0];
assert.equal(first.official, false);
// A separate browser has a separate signing identity and cannot claim this id.
+1 -1
View File
@@ -42,7 +42,7 @@ async function run(name, command, args, timeout = 360000) {
if (built) {
await run('marketplace-build', ...npmCommand(['run', 'build', '--prefix', 'marketplace']));
await run('focused', process.execPath, ['node_modules/vitest/vitest.mjs', 'run',
'__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/extension-trust.test.ts', '__tests__/extension-releases.test.ts', '__tests__/extension-marketplace.test.ts', '__tests__/extension-author.test.ts', '__tests__/plugins.test.ts', '__tests__/extension-explore.test.ts',
'__tests__/db-reopen-on-replace.test.ts', '__tests__/status-json.test.ts', '__tests__/sync.test.ts', '__tests__/concurrent-locking.test.ts',
'--maxWorkers=2', '--minWorkers=1', '--reporter=default', '--reporter=json', `--outputFile.json=${path.join(out, 'focused.json')}`]);
await run('compiled-workers', process.execPath, ['--test', 'scripts/validation/extensions-runtime.test.cjs']);
+2 -2
View File
@@ -6,7 +6,7 @@ import { NODE_KINDS, EDGE_KINDS, LANGUAGES, type Node, type Edge } from '../type
import type { FrameworkResolver, UnresolvedRef } from '../resolution/types';
import type { CodeGraphPlugin, PluginDiagnostic, PluginEntry, ResolvedPlugin } from './api';
import { emptyRegistry, type PluginRegistry } from './registry';
import { inside, packageDigest, validateManifest, sha256 } from './package';
import { inside, packageDigest, trustedPackageDigest, validateManifest, sha256 } from './package';
import { loadPluginEntries } from '../project-config';
import { version as engineVersion } from '../../package.json';
@@ -40,7 +40,7 @@ function resolveEntry(root: string, entry: PluginEntry): ResolvedPlugin {
if (entry.name.startsWith('./') || entry.name.startsWith('managed:')) {
let trusted: Record<string, string> = {};
try { trusted = JSON.parse(fs.readFileSync(path.join(pluginDirectory(root), 'trust.json'), 'utf8')); } catch { /* no trust yet */ }
if (trusted[realRoot] !== digest) throw new Error('Extension code is not trusted; install it with codegraph extensions install');
if (trustedPackageDigest(realRoot, trusted) !== digest) throw new Error('Extension code is not trusted; install it with codegraph extensions install');
}
if (entry.version && entry.version !== pkg.version) throw new Error('Installed version does not match project pin');
if (entry.name.startsWith('managed:') && entry.name.slice(8) !== manifest.id) throw new Error('Managed extension identity mismatch');
+19
View File
@@ -92,3 +92,22 @@ export function packageDigest(root: string): string {
walk(root);
return hash.digest('hex');
}
/** Trust follows the same existing directory across filesystem case/short-name
* aliases, never a lowercased string or a different directory with equal bytes. */
export function trustedPackageDigest(directory: string, trusted: Record<string, unknown>): string | undefined {
const real = fs.realpathSync(directory);
if (typeof trusted[real] === 'string') return trusted[real] as string;
const target = fs.statSync(real, { bigint: true });
if (!target.isDirectory() || target.ino === 0n) return;
const matches = new Set<string>();
for (const [approved, digest] of Object.entries(trusted)) {
if (typeof digest !== 'string' || !path.isAbsolute(approved)) continue;
try {
const candidate = fs.statSync(approved, { bigint: true });
if (candidate.isDirectory() && candidate.dev === target.dev && candidate.ino === target.ino) matches.add(digest);
} catch { /* An absent historical package is not this directory. */ }
}
if (matches.size > 1) throw new Error('Conflicting trust records for aliases of the same package directory');
return matches.values().next().value;
}
+2 -2
View File
@@ -6,7 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { channel } from 'node:diagnostics_channel';
import { createDatabase } from '../db/sqlite-adapter';
import { clearProjectConfigCache } from '../project-config';
import { sha256, packageDigest, type ExtensionPackage } from './package';
import { sha256, packageDigest, trustedPackageDigest, type ExtensionPackage } from './package';
const context = new AsyncLocalStorage<string>();
const transitions = channel('codegraph.extension.transaction');
@@ -156,7 +156,7 @@ function verifyPackages(root: string, config: string | null, trust: string | nul
try {
const pkg = object(read(path.join(packageRoot, 'package.json')));
if (pkg.version !== entry.version || (pkg.codegraph as { id?: string })?.id !== entry.name.slice(8) ||
packageDigest(packageRoot) !== trusted[fs.realpathSync(packageRoot)]) throw new Error('version/identity/trust mismatch');
packageDigest(packageRoot) !== trustedPackageDigest(packageRoot, trusted)) throw new Error('version/identity/trust mismatch');
} catch (error) { fail(root, `installed package ${entry.name} is missing or changed (${String(error)}); restore the recorded package bytes/trust from backup before recovery`); }
}
}