diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e03fb1d8..1b46c39c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/__tests__/extension-trust.test.ts b/__tests__/extension-trust.test.ts new file mode 100644 index 000000000..983b69a45 --- /dev/null +++ b/__tests__/extension-trust.test.ts @@ -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/); +}); diff --git a/marketplace/public/app.js b/marketplace/public/app.js index 91d058d42..293a90b90 100644 --- a/marketplace/public/app.js +++ b/marketplace/public/app.js @@ -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.');} }); diff --git a/scripts/validation/marketplace-browser.cjs b/scripts/validation/marketplace-browser.cjs index c89775ee9..b4b4db5e6 100644 --- a/scripts/validation/marketplace-browser.cjs +++ b/scripts/validation/marketplace-browser.cjs @@ -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. diff --git a/scripts/validation/native-validation.cjs b/scripts/validation/native-validation.cjs index e36ac0517..5ca8394af 100644 --- a/scripts/validation/native-validation.cjs +++ b/scripts/validation/native-validation.cjs @@ -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']); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 529dd1ccd..34f2e4fb1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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 = {}; 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'); diff --git a/src/plugins/package.ts b/src/plugins/package.ts index 121740c9d..f4ff84fc5 100644 --- a/src/plugins/package.ts +++ b/src/plugins/package.ts @@ -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 | 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(); + 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; +} diff --git a/src/plugins/recovery.ts b/src/plugins/recovery.ts index e0badd7a2..526d5a624 100644 --- a/src/plugins/recovery.ts +++ b/src/plugins/recovery.ts @@ -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(); 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`); } } }