diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs index 42be93519e..22ad02f614 100644 --- a/apps/desktop/scripts/before-build.mjs +++ b/apps/desktop/scripts/before-build.mjs @@ -101,8 +101,8 @@ function writeMsixExtensions() { hermes://copilot-key/start?state=Tap @@ -117,6 +117,15 @@ ${aliases}` fs.writeFileSync(file, copilot) } +// The manifest fragments above are string templates; a display name or a +// payload-declared launcher stem carrying `&`, `<` or `"` would otherwise +// corrupt the XML makeappx reads (an opaque 0x80080204 at best, a different +// alias at worst). +/** @param {string} value */ +export function xmlAttribute(value) { + return String(value).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`) +} + /** * One uap5:Extension carries the aliases declared by the payload. * Exported pure for tests. @@ -128,8 +137,8 @@ export function appExecutionAliasApplications(launchers, identity) { // Give each CLI its own hidden application, with one extension per app. return launchers.map((name, index) => { const executable = ['app', 'resources', 'agent-payload', 'bin', `${name}.exe`].join(String.fromCharCode(92)) - return ` - + ${appExecutionAliasExtensions([name])} @@ -147,11 +156,11 @@ export function appExecutionAliasExtensions(launchers) { return ` ${launchers - .map((name) => ` `) + .map((name) => ` `) .join('\n')} ` diff --git a/apps/desktop/scripts/cli-launchers.test.mjs b/apps/desktop/scripts/cli-launchers.test.mjs index 7405b32bf4..3eaf825dc0 100644 --- a/apps/desktop/scripts/cli-launchers.test.mjs +++ b/apps/desktop/scripts/cli-launchers.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { appExecutionAliasExtensions } from './before-build.mjs' +import { appExecutionAliasApplications, appExecutionAliasExtensions, xmlAttribute } from './before-build.mjs' test('one MSIX extension consumes the launchers declared by the payload', () => { const names = ['custom-cli', 'another-cli'] @@ -12,3 +12,12 @@ test('one MSIX extension consumes the launchers declared by the payload', () => assert.ok(!xml.includes('desktop6:Service')) assert.equal(appExecutionAliasExtensions([]), '') }) + +test('manifest fragments escape every interpolated attribute value', () => { + const xml = appExecutionAliasApplications(['odd"&' }) + const values = [...xml.matchAll(/="([^"]*)"/g)].map((m) => m[1].replace(/&#\d+;/g, '')) + assert.ok(values.length > 0 && values.every((v) => !/[&<>"']/.test(v)), xml) + assert.ok(xml.includes('DisplayName="Hermes & "Friends" <beta>"')) + assert.ok(xml.includes('Alias="odd"&<name.exe"')) + assert.equal(xmlAttribute('plain-name'), 'plain-name') +})