mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(extensions): preserve trust across native path aliases and publisher input
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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.');}
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user