fix: validate local web manifest icon refs (#3596)

Make derived manifest.json rewriting fail fast for missing local icon
refs instead of falling back to unhashed root paths.

Keep external and data URLs unchanged, and add regression coverage for
root-relative local icons, missing local icons, and passthrough
external/data refs.

If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

## Description:

Describe the PR.

## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-04-05 21:39:18 +02:00
committed by GitHub
parent aa06754579
commit 2476d6844d
2 changed files with 113 additions and 16 deletions
+38 -5
View File
@@ -4,7 +4,6 @@ import { globSync } from "glob";
import path from "path";
import {
type AssetManifest,
buildAssetUrl,
encodeAssetPath,
normalizeAssetPath,
} from "../core/AssetUrls";
@@ -97,6 +96,12 @@ function getEmittedAssetRelativePath(
return path.posix.relative(emittedFromDir, emittedTargetPath);
}
function isExternalAssetReference(referencePath: string): boolean {
return (
/^[a-z][a-z0-9+.-]*:/i.test(referencePath) || referencePath.startsWith("//")
);
}
function renderWebManifestAsset({
resourcesDir,
assetManifest,
@@ -105,10 +110,38 @@ function renderWebManifestAsset({
const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as {
icons?: Array<{ src?: string }>;
};
manifest.icons = manifest.icons?.map((icon) => ({
...icon,
src: buildAssetUrl(icon.src ?? "", assetManifest),
}));
manifest.icons = manifest.icons?.map((icon) => {
const src = icon.src;
if (src === undefined) {
return icon;
}
if (src.trim().length === 0) {
throw new Error(
"Derived asset manifest.json contains an icon with a blank src",
);
}
if (isExternalAssetReference(src)) {
return icon;
}
const referencedAssetPath = resolveDerivedAssetReference(
"manifest.json",
src,
);
const referencedHashedUrl = assetManifest[referencedAssetPath];
if (!referencedHashedUrl) {
throw new Error(
`Derived asset manifest.json references ${referencedAssetPath}, but it is missing from the asset manifest`,
);
}
return {
...icon,
src: referencedHashedUrl,
};
});
return `${JSON.stringify(manifest, null, 2)}\n`;
}
+75 -11
View File
@@ -69,6 +69,23 @@ describe("PublicAssetManifest", () => {
);
}
async function writeWebManifestFixture(
resourcesDir: string,
icons: Array<{ src?: string }>,
): Promise<void> {
await fs.writeFile(
path.join(resourcesDir, "manifest.json"),
JSON.stringify(
{
name: "OpenFront",
icons,
},
null,
2,
),
);
}
afterEach(async () => {
clearPublicAssetManifestCache();
if (tempDir) {
@@ -81,17 +98,9 @@ describe("PublicAssetManifest", () => {
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await fs.writeFile(
path.join(resourcesDir, "manifest.json"),
JSON.stringify(
{
name: "OpenFront",
icons: [{ src: "icons/app-icon.png" }],
},
null,
2,
),
);
await writeWebManifestFixture(resourcesDir, [
{ src: "icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
@@ -125,6 +134,61 @@ describe("PublicAssetManifest", () => {
expect(firstOutput).not.toContain(secondIconHref);
});
test("rewrites root-relative web manifest icon paths to hashed URLs", async () => {
const { resourcesDir, outDir } = await createTempResources();
await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true });
await writeWebManifestFixture(resourcesDir, [
{ src: "/icons/app-icon.png" },
]);
await fs.writeFile(
path.join(resourcesDir, "icons", "app-icon.png"),
"icon-v1",
"utf8",
);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain(assetManifest["icons/app-icon.png"]);
expect(emittedManifest).not.toContain('"/icons/app-icon.png"');
});
test("fails when web manifest references a missing local icon", async () => {
const { resourcesDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [{ src: "icons/missing.png" }]);
expect(() => buildPublicAssetManifest(resourcesDir)).toThrow(
/manifest\.json references icons\/missing\.png/i,
);
});
test("leaves external and data web manifest icon refs unchanged", async () => {
const { resourcesDir, outDir } = await createTempResources();
await writeWebManifestFixture(resourcesDir, [
{ src: "https://cdn.example.com/app-icon.png" },
{ src: "data:image/png;base64,AAA" },
]);
const assetManifest = buildPublicAssetManifest(resourcesDir);
createHashedPublicAssetFiles(resourcesDir, outDir, assetManifest);
const emittedManifest = await emitHashedAsset(
outDir,
assetManifest["manifest.json"],
);
expect(emittedManifest).toContain("https://cdn.example.com/app-icon.png");
expect(emittedManifest).toContain("data:image/png;base64,AAA");
});
test("rewrites BMFont XML page filenames to hashed relative paths", async () => {
const { resourcesDir, outDir } = await createTempResources();