desktop: escape the attribute values interpolated into the MSIX fragments

before-build.mjs builds the msix-extensions.xml fragment and the hidden
CLI <Application> entries from string templates, interpolating the
display name and the payload-declared launcher stems raw. A value with
`&`, `<` or `"` would corrupt the manifest makeappx reads (an opaque
0x80080204 at best, a differently named alias at worst). Every
interpolated attribute now goes through xmlAttribute().
This commit is contained in:
ethernet
2026-09-21 18:51:46 -04:00
parent 241a9ddc8e
commit fa9e899d4e
2 changed files with 25 additions and 7 deletions

View File

@@ -101,8 +101,8 @@ function writeMsixExtensions() {
<uap3:AppExtension
Name="com.microsoft.windows.copilotkeyprovider"
Id="CopilotKeyProvider"
DisplayName="${displayName}"
Description="Launch ${displayName} with the Copilot key"
DisplayName="${xmlAttribute(displayName)}"
Description="Launch ${xmlAttribute(displayName)} with the Copilot key"
PublicFolder="Public">
<uap3:Properties>
<SingleTap>hermes://copilot-key/start?state=Tap</SingleTap>
@@ -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 `<Application Id="${identity.appNamePascal}Cli${index}" Executable="${executable}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="${identity.displayName}" Description="${identity.displayName} CLI"
return `<Application Id="${xmlAttribute(identity.appNamePascal)}Cli${index}" Executable="${xmlAttribute(executable)}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="${xmlAttribute(identity.displayName)}" Description="${xmlAttribute(identity.displayName)} CLI"
Square150x150Logo="assets\\Square150x150Logo.png" Square44x44Logo="assets\\Square44x44Logo.png"
BackgroundColor="transparent" AppListEntry="none" />
<Extensions>${appExecutionAliasExtensions([name])}</Extensions>
@@ -147,11 +156,11 @@ export function appExecutionAliasExtensions(launchers) {
return `<uap5:Extension
xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"
Category="windows.appExecutionAlias"
Executable="${executable(launchers[0])}"
Executable="${xmlAttribute(executable(launchers[0]))}"
EntryPoint="Windows.FullTrustApplication">
<uap5:AppExecutionAlias>
${launchers
.map((name) => ` <uap5:ExecutionAlias Alias="${name}.exe" />`)
.map((name) => ` <uap5:ExecutionAlias Alias="${xmlAttribute(name)}.exe" />`)
.join('\n')}
</uap5:AppExecutionAlias>
</uap5:Extension>`

View File

@@ -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"&<name'], { appNamePascal: 'Hermes', displayName: 'Hermes & "Friends" <beta>' })
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 &#38; &#34;Friends&#34; &#60;beta&#62;"'))
assert.ok(xml.includes('Alias="odd&#34;&#38;&#60;name.exe"'))
assert.equal(xmlAttribute('plain-name'), 'plain-name')
})