mirror of
https://github.com/def-peter/obsidian-aera-theme.git
synced 2026-07-22 05:00:27 +00:00
fix: harden theme policy checks
This commit is contained in:
parent
094aabffc3
commit
6242ad8084
3 changed files with 335 additions and 44 deletions
|
|
@ -7,29 +7,60 @@ import {
|
|||
validateVersions,
|
||||
} from "./theme-policy.mjs";
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
async function readJson(path) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
throw new Error(`could not read ${path}: ${error.message}`);
|
||||
}
|
||||
|
||||
const manifest = await readJson("manifest.json");
|
||||
const packageJson = await readJson("package.json");
|
||||
const versions = await readJson("versions.json");
|
||||
const css = await readFile("theme.css", "utf8");
|
||||
|
||||
const tagIndex = process.argv.indexOf("--release-tag");
|
||||
const tag = tagIndex === -1 ? "" : process.argv[tagIndex + 1];
|
||||
const errors = [
|
||||
...validateManifest(manifest),
|
||||
...validateVersions(manifest, versions),
|
||||
...validateCss(css),
|
||||
...validateReleaseTag(manifest, tag),
|
||||
];
|
||||
|
||||
if (packageJson.version !== manifest.version) {
|
||||
errors.push("package.json version must equal manifest version");
|
||||
try {
|
||||
return JSON.parse(contents);
|
||||
} catch (error) {
|
||||
throw new Error(`could not parse ${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach((error) => console.error(`ERROR: ${error}`));
|
||||
process.exit(1);
|
||||
function readReleaseTag(args) {
|
||||
const tagIndex = args.indexOf("--release-tag");
|
||||
if (tagIndex === -1) return "";
|
||||
|
||||
const tag = args[tagIndex + 1];
|
||||
if (!tag || tag.startsWith("--")) {
|
||||
throw new Error("--release-tag requires a value");
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
console.log("Theme policy checks passed");
|
||||
async function main() {
|
||||
const tag = readReleaseTag(process.argv.slice(2));
|
||||
const manifest = await readJson("manifest.json");
|
||||
const packageJson = await readJson("package.json");
|
||||
const versions = await readJson("versions.json");
|
||||
const css = await readFile("theme.css", "utf8");
|
||||
const errors = [
|
||||
...validateManifest(manifest),
|
||||
...validateVersions(manifest, versions),
|
||||
...validateCss(css),
|
||||
...validateReleaseTag(manifest, tag),
|
||||
];
|
||||
|
||||
if (packageJson.version !== manifest?.version) {
|
||||
errors.push("package.json version must equal manifest version");
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach((error) => console.error(`ERROR: ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Theme policy checks passed");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`ERROR: ${message.replace(/\s+/g, " ").trim()}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import postcss from "postcss";
|
||||
|
||||
const allowedKeys = new Set([
|
||||
"author",
|
||||
"authorUrl",
|
||||
|
|
@ -6,13 +8,24 @@ const allowedKeys = new Set([
|
|||
"name",
|
||||
"version",
|
||||
]);
|
||||
const semver = /^\d+\.\d+\.\d+$/;
|
||||
const requiredKeys = ["author", "minAppVersion", "name", "version"];
|
||||
const semver = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
||||
|
||||
function isPlainObject(value) {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function validateManifest(manifest) {
|
||||
if (!isPlainObject(manifest)) {
|
||||
return ["manifest.json must be a plain object"];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const key of ["author", "minAppVersion", "name", "version"]) {
|
||||
if (typeof manifest[key] !== "string" || !manifest[key]) {
|
||||
for (const key of requiredKeys) {
|
||||
if (typeof manifest[key] !== "string" || !manifest[key].trim()) {
|
||||
errors.push(`manifest.json requires ${key}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +36,22 @@ export function validateManifest(manifest) {
|
|||
}
|
||||
}
|
||||
|
||||
if (Object.hasOwn(manifest, "authorUrl") && typeof manifest.authorUrl !== "string") {
|
||||
errors.push("manifest.json authorUrl must be a string");
|
||||
}
|
||||
|
||||
if (Object.hasOwn(manifest, "fundingUrl")) {
|
||||
const fundingUrlIsValid =
|
||||
typeof manifest.fundingUrl === "string" ||
|
||||
(isPlainObject(manifest.fundingUrl) &&
|
||||
Object.values(manifest.fundingUrl).every((value) => typeof value === "string"));
|
||||
if (!fundingUrlIsValid) {
|
||||
errors.push(
|
||||
"manifest.json fundingUrl must be a string or plain object with string values",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.name !== "Aera") {
|
||||
errors.push("theme name must remain Aera");
|
||||
}
|
||||
|
|
@ -37,32 +66,88 @@ export function validateManifest(manifest) {
|
|||
}
|
||||
|
||||
export function validateVersions(manifest, versions) {
|
||||
return versions[manifest.version] === manifest.minAppVersion
|
||||
? []
|
||||
: ["versions.json must map manifest version to minAppVersion"];
|
||||
if (!isPlainObject(versions)) {
|
||||
return ["versions.json must be a plain object"];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
for (const [version, minAppVersion] of Object.entries(versions)) {
|
||||
if (!semver.test(version)) {
|
||||
errors.push(`versions.json version key ${version} must use x.y.z`);
|
||||
}
|
||||
if (typeof minAppVersion !== "string" || !semver.test(minAppVersion)) {
|
||||
errors.push(`versions.json minAppVersion for ${version} must use x.y.z`);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestVersion = manifest?.version;
|
||||
const manifestMinAppVersion = manifest?.minAppVersion;
|
||||
if (
|
||||
typeof manifestVersion !== "string" ||
|
||||
typeof manifestMinAppVersion !== "string" ||
|
||||
versions[manifestVersion] !== manifestMinAppVersion
|
||||
) {
|
||||
errors.push("versions.json must map manifest version to minAppVersion");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateCss(css) {
|
||||
const checks = [
|
||||
[/!important\b/i, "theme.css must not contain !important"],
|
||||
[/:has\s*\(/i, "theme.css must not contain :has()"],
|
||||
[/url\(\s*["']?https?:/i, "theme.css must not load a remote URL"],
|
||||
[/--font-text-size\s*:/i, "theme.css must not assign --font-text-size"],
|
||||
[/--file-line-width\s*:/i, "theme.css must not assign --file-line-width"],
|
||||
[
|
||||
/--font-interface-theme\s*:/i,
|
||||
"theme.css must not assign --font-interface-theme",
|
||||
],
|
||||
[/sourceMappingURL/i, "theme.css must not contain source map references"],
|
||||
];
|
||||
let root;
|
||||
try {
|
||||
root = postcss.parse(css, { from: "theme.css" });
|
||||
} catch (error) {
|
||||
return [`theme.css must contain valid CSS: ${error.reason ?? error.message}`];
|
||||
}
|
||||
|
||||
return checks
|
||||
.filter(([pattern]) => pattern.test(css))
|
||||
.map(([, message]) => message);
|
||||
const errors = new Set();
|
||||
const remoteUrl = /url\(\s*["']?\s*(?:https?:)?\/\//i;
|
||||
|
||||
root.walkRules((rule) => {
|
||||
if (/:has\s*\(/i.test(rule.selector)) {
|
||||
errors.add("theme.css must not contain :has()");
|
||||
}
|
||||
});
|
||||
|
||||
root.walkDecls((declaration) => {
|
||||
if (declaration.important) {
|
||||
errors.add("theme.css must not contain !important");
|
||||
}
|
||||
|
||||
const property = declaration.prop.toLowerCase();
|
||||
if (property === "--font-text-size") {
|
||||
errors.add("theme.css must not assign --font-text-size");
|
||||
}
|
||||
if (property === "--file-line-width") {
|
||||
errors.add("theme.css must not assign --file-line-width");
|
||||
}
|
||||
if (property === "--font-interface-theme") {
|
||||
errors.add("theme.css must not assign --font-interface-theme");
|
||||
}
|
||||
if (remoteUrl.test(declaration.value)) {
|
||||
errors.add("theme.css must not load a remote URL");
|
||||
}
|
||||
});
|
||||
|
||||
root.walkAtRules("import", (atRule) => {
|
||||
const remoteImport = /^\s*["']?\s*(?:https?:)?\/\//i;
|
||||
if (remoteImport.test(atRule.params) || remoteUrl.test(atRule.params)) {
|
||||
errors.add("theme.css must not load a remote URL");
|
||||
}
|
||||
});
|
||||
|
||||
root.walkComments((comment) => {
|
||||
if (/sourceMappingURL/i.test(comment.text)) {
|
||||
errors.add("theme.css must not contain source map references");
|
||||
}
|
||||
});
|
||||
|
||||
return [...errors];
|
||||
}
|
||||
|
||||
export function validateReleaseTag(manifest, tag) {
|
||||
return !tag || tag === manifest.version
|
||||
return !tag || tag === manifest?.version
|
||||
? []
|
||||
: [`release tag ${tag} must equal ${manifest.version}`];
|
||||
: [`release tag ${tag} must equal ${manifest?.version}`];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
validateCss,
|
||||
|
|
@ -14,6 +19,32 @@ const validManifest = {
|
|||
minAppVersion: "1.12.7",
|
||||
author: "Peter",
|
||||
};
|
||||
const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
|
||||
const cliPath = join(repoRoot, "scripts/check-theme.mjs");
|
||||
|
||||
function runCli(args = [], cwd = repoRoot) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function createThemeFixture(t, overrides = {}) {
|
||||
const directory = mkdtempSync(join(tmpdir(), "aera-theme-policy-"));
|
||||
const files = {
|
||||
"manifest.json": `${JSON.stringify(validManifest)}\n`,
|
||||
"package.json": `${JSON.stringify({ version: validManifest.version })}\n`,
|
||||
"versions.json": `${JSON.stringify({ "0.1.0": "1.12.7" })}\n`,
|
||||
"theme.css": ":root { --aera-accent: #3d6b5f; }\n",
|
||||
...overrides,
|
||||
};
|
||||
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
if (contents !== null) writeFileSync(join(directory, name), contents);
|
||||
}
|
||||
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
test("validateManifest accepts the Aera theme manifest", () => {
|
||||
assert.deepEqual(validateManifest(validManifest), []);
|
||||
|
|
@ -28,6 +59,56 @@ test("validateManifest rejects plugin-only description metadata", () => {
|
|||
assert.ok(errors.some((error) => error.includes("description")));
|
||||
});
|
||||
|
||||
test("validateManifest rejects values that are not plain objects", () => {
|
||||
for (const manifest of [null, [], new Date()]) {
|
||||
assert.match(validateManifest(manifest).join("\n"), /plain object/);
|
||||
}
|
||||
});
|
||||
|
||||
test("validateManifest requires non-empty trimmed strings", () => {
|
||||
for (const key of ["author", "minAppVersion", "name", "version"]) {
|
||||
const errors = validateManifest({ ...validManifest, [key]: " \t " });
|
||||
assert.ok(errors.some((error) => error.includes(`requires ${key}`)));
|
||||
}
|
||||
});
|
||||
|
||||
test("validateManifest validates optional URL metadata shapes", () => {
|
||||
assert.deepEqual(
|
||||
validateManifest({
|
||||
...validManifest,
|
||||
authorUrl: "https://example.com",
|
||||
fundingUrl: { GitHub: "https://github.com/sponsors/example" },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
assert.match(
|
||||
validateManifest({ ...validManifest, authorUrl: 42 }).join("\n"),
|
||||
/authorUrl.*string/,
|
||||
);
|
||||
assert.match(
|
||||
validateManifest({ ...validManifest, fundingUrl: { GitHub: 42 } }).join("\n"),
|
||||
/fundingUrl.*string/,
|
||||
);
|
||||
assert.match(
|
||||
validateManifest({ ...validManifest, fundingUrl: ["https://example.com"] }).join(
|
||||
"\n",
|
||||
),
|
||||
/fundingUrl.*string|plain object/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateManifest rejects semantic versions with leading zeroes", () => {
|
||||
assert.match(
|
||||
validateManifest({ ...validManifest, version: "01.2.3" }).join("\n"),
|
||||
/manifest version.*x\.y\.z/,
|
||||
);
|
||||
assert.match(
|
||||
validateManifest({ ...validManifest, minAppVersion: "1.02.3" }).join("\n"),
|
||||
/minAppVersion.*x\.y\.z/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateVersions requires the current version to map to minAppVersion", () => {
|
||||
assert.deepEqual(validateVersions(validManifest, { "0.1.0": "1.12.7" }), []);
|
||||
|
||||
|
|
@ -35,6 +116,27 @@ test("validateVersions requires the current version to map to minAppVersion", ()
|
|||
assert.ok(errors.some((error) => error.includes("versions.json")));
|
||||
});
|
||||
|
||||
test("validateVersions requires a plain object with strict semantic versions", () => {
|
||||
for (const versions of [null, []]) {
|
||||
assert.match(validateVersions(validManifest, versions).join("\n"), /plain object/);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
validateVersions(validManifest, {
|
||||
"0.1.0": "1.12.7",
|
||||
"01.0.0": "1.12.7",
|
||||
}).join("\n"),
|
||||
/version key.*x\.y\.z/,
|
||||
);
|
||||
assert.match(
|
||||
validateVersions(validManifest, {
|
||||
"0.1.0": "1.12.7",
|
||||
"0.2.0": "1.02.3",
|
||||
}).join("\n"),
|
||||
/minAppVersion.*x\.y\.z/,
|
||||
);
|
||||
});
|
||||
|
||||
const forbiddenCss = [
|
||||
["!important", ".notice { color: red !important; }", "!important"],
|
||||
[":has(", ".item:has(.active) { color: red; }", ":has"],
|
||||
|
|
@ -66,6 +168,27 @@ test("validateCss accepts CSS that follows theme policy", () => {
|
|||
assert.deepEqual(validateCss(":root { --aera-accent: #3d6b5f; }"), []);
|
||||
});
|
||||
|
||||
test("validateCss rejects remote imports and protocol-relative URLs", () => {
|
||||
for (const css of [
|
||||
'@import "https://example.com/theme.css";',
|
||||
"@import url(//example.com/theme.css);",
|
||||
".hero { background: url(//example.com/image.png); }",
|
||||
]) {
|
||||
assert.match(validateCss(css).join("\n"), /remote URL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("validateCss ignores policy-like text inside ordinary comments", () => {
|
||||
const css = `
|
||||
/* !important :has(.item) url(https://example.com/image.png)
|
||||
--font-text-size: 18px; --file-line-width: 48rem;
|
||||
--font-interface-theme: Inter; */
|
||||
:root { --aera-accent: #3d6b5f; }
|
||||
`;
|
||||
|
||||
assert.deepEqual(validateCss(css), []);
|
||||
});
|
||||
|
||||
test("validateReleaseTag accepts an empty or matching release tag", () => {
|
||||
assert.deepEqual(validateReleaseTag(validManifest, ""), []);
|
||||
assert.deepEqual(validateReleaseTag(validManifest, "0.1.0"), []);
|
||||
|
|
@ -75,3 +198,55 @@ test("validateReleaseTag rejects a mismatched release tag", () => {
|
|||
const errors = validateReleaseTag(validManifest, "0.2.0");
|
||||
assert.ok(errors.some((error) => error.includes("0.2.0")));
|
||||
});
|
||||
|
||||
test("check-theme CLI accepts an omitted or matching release tag", () => {
|
||||
for (const args of [[], ["--release-tag", "0.1.0"]]) {
|
||||
const result = runCli(args);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Theme policy checks passed/);
|
||||
}
|
||||
});
|
||||
|
||||
test("check-theme CLI rejects a mismatched release tag", () => {
|
||||
const result = runCli(["--release-tag", "1.0.0"]);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /^ERROR: release tag 1\.0\.0 must equal 0\.1\.0\n$/);
|
||||
});
|
||||
|
||||
test("check-theme CLI rejects a release-tag option without a value", () => {
|
||||
for (const args of [["--release-tag"], ["--release-tag", "--other-option"]]) {
|
||||
const result = runCli(args);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /^ERROR: --release-tag requires a value\n$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("check-theme CLI reports missing JSON files without a stack trace", (t) => {
|
||||
const directory = createThemeFixture(t, { "manifest.json": null });
|
||||
const result = runCli([], directory);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /^ERROR: .+manifest\.json.+\n$/);
|
||||
assert.equal(result.stderr.trim().split("\n").length, 1);
|
||||
assert.doesNotMatch(result.stderr, /\n\s+at /);
|
||||
});
|
||||
|
||||
test("check-theme CLI reports malformed JSON without a stack trace", (t) => {
|
||||
const directory = createThemeFixture(t, { "versions.json": "{broken json\n" });
|
||||
const result = runCli([], directory);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /^ERROR: .+versions\.json.+\n$/);
|
||||
assert.equal(result.stderr.trim().split("\n").length, 1);
|
||||
assert.doesNotMatch(result.stderr, /\n\s+at /);
|
||||
});
|
||||
|
||||
test("check-theme CLI reports manifest schema errors for JSON null", (t) => {
|
||||
const directory = createThemeFixture(t, { "manifest.json": "null\n" });
|
||||
const result = runCli([], directory);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /ERROR: manifest\.json must be a plain object/);
|
||||
assert.doesNotMatch(result.stderr, /Cannot read properties/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue