mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
Accept nested extensions/<name>/extension.mjs in external-plugin canvas checks (#2403)
* Accept nested extensions/<name>/extension.mjs in external-plugin canvas checks The external-plugin canvas structure check (quality gate) and intake validation both hardcoded a flat extensions/extension.mjs entry point, falsely rejecting the documented nested extensions/<name>/extension.mjs layout that installs and runs fine. Scan the extensions/ directory for a nested subfolder containing extension.mjs while still accepting the flat form for backward compatibility. Applied to both runCanvasStructureGate (git-object lookups) and validateCanvasPluginMetadata (Contents API), keeping them behaviorally aligned. Added regression coverage for both paths. Fixes #2402 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Harden nested canvas extension detection after adversarial review Address multi-model review findings on the nested canvas extension fix: - Quality gate: enumerate extensions/ via 'git ls-tree -z' with spawnSync (NUL-delimited, untruncated) so large directories no longer drop the real entry past the 12KB output cap. - Intake: decouple the flat extensions/extension.mjs check from the directory listing, require an array listing (Array.isArray) before treating it as a directory, and surface an unverifiable (warning) result instead of a false rejection when the listing or a nested lookup hits a transient API error. - Add regression tests: nested entry beyond the legacy output cap (gate) and unverifiable/flat-still-accepted paths when the listing errors (intake). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enumerate canvas extensions via a single recursive Git Trees call in intake Address PR review: the Contents API caps directory listings at 1,000 entries and required one request per extension subfolder, so a nested entry beyond the cap could be falsely rejected (the same truncation class the git gate avoids) and large repos risked latency / rate-limit exhaustion. Replace the per-subfolder Contents API enumeration with one recursive 'git/trees/<locator>?recursive=1' fetch and inspect 'extensions/extension.mjs' and immediate 'extensions/<name>/extension.mjs' paths locally. A truncated tree without a located entry point is reported as unverifiable (warning) rather than rejected, and refs are normalized so 'refs/tags/<tag>' resolves as a tree-ish. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Bound canvas extension discovery to plugin scope Address reviewer feedback on unbounded scaling for untrusted/large repos: - Quality gate: replace the per-candidate-directory git cat-file spawns in locateCanvasEntryPoint with a single recursive git ls-tree over the extensions subtree, classifying flat/nested entry points in memory. Process count is now constant regardless of how many folders live under extensions/. - Intake: stop fetching the recursive git tree from the repo root (which a large unrelated monorepo can push past the Trees API truncation limit and never validate). Walk to the plugin's extensions directory one level at a time to resolve its tree SHA, then fetch only that subtree recursively, so verifiability depends on the plugin's own size, not the whole repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>
This commit is contained in:
+124
-29
@@ -380,7 +380,92 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
function buildGitTreePath(repo, treeish, { recursive = false } = {}) {
|
||||||
|
const encodedRepo = encodeRepoPath(repo);
|
||||||
|
const query = recursive ? "?recursive=1" : "";
|
||||||
|
return `/repos/${encodedRepo}/git/trees/${encodeURIComponent(treeish)}${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTreeish(locator) {
|
||||||
|
const value = String(locator ?? "").trim();
|
||||||
|
// The Git Trees API takes the tree-ish as a single path segment. A full "refs/tags/<tag>"
|
||||||
|
// ref would break that, so reduce it to the bare tag name; commit SHAs and simple tag
|
||||||
|
// names pass through unchanged.
|
||||||
|
return value.startsWith("refs/tags/") ? value.slice("refs/tags/".length) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the tree SHA of a directory by walking the path one level at a time. Each hop is a
|
||||||
|
// non-recursive tree fetch of a single directory, so the work is bounded by the path depth and
|
||||||
|
// is independent of the overall repository size — unlike a root recursive fetch, which a large
|
||||||
|
// unrelated monorepo can push over the API's truncation limit and never validate.
|
||||||
|
async function resolveDirectoryTreeSha(repo, treeish, segments, token) {
|
||||||
|
let currentTreeish = treeish;
|
||||||
|
for (const segment of segments) {
|
||||||
|
const response = await fetchGitHubJson(buildGitTreePath(repo, currentTreeish), token);
|
||||||
|
if (response.kind !== "found" || !Array.isArray(response.data?.tree)) {
|
||||||
|
return { status: "apiError" };
|
||||||
|
}
|
||||||
|
if (response.data.truncated) {
|
||||||
|
// A single directory level exceeded the response limit; presence is unverifiable.
|
||||||
|
return { status: "apiError" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = response.data.tree.find((entry) => entry?.path === segment);
|
||||||
|
if (!match) {
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
if (match.type !== "tree") {
|
||||||
|
return { status: "notDirectory" };
|
||||||
|
}
|
||||||
|
currentTreeish = match.sha;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "found", treeSha: currentTreeish };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect the (recursively fetched) "extensions" subtree for the plugin's canvas extension
|
||||||
|
// entry point. Paths are relative to "extensions/", so the flat form is "extension.mjs" and a
|
||||||
|
// nested form is "<name>/extension.mjs". Scoping the recursive fetch to this subtree keeps the
|
||||||
|
// lookup complete without depending on the size of the rest of the repository.
|
||||||
|
function analyzeCanvasExtensionSubtree(subtreeEntries) {
|
||||||
|
let flatIsBlob = false;
|
||||||
|
let flatIsTree = false;
|
||||||
|
let nestedEntryPath = null;
|
||||||
|
|
||||||
|
for (const entry of subtreeEntries) {
|
||||||
|
const entryPath = entry?.path;
|
||||||
|
if (typeof entryPath !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryPath === "extension.mjs") {
|
||||||
|
if (entry.type === "blob") {
|
||||||
|
flatIsBlob = true;
|
||||||
|
} else if (entry.type === "tree") {
|
||||||
|
flatIsTree = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = entryPath.split("/");
|
||||||
|
if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob") {
|
||||||
|
nestedEntryPath = nestedEntryPath ?? `extensions/${entryPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatIsBlob) {
|
||||||
|
return { status: "found", entryPath: "extensions/extension.mjs" };
|
||||||
|
}
|
||||||
|
if (nestedEntryPath) {
|
||||||
|
return { status: "found", entryPath: nestedEntryPath };
|
||||||
|
}
|
||||||
|
if (flatIsTree) {
|
||||||
|
return { status: "notFile" };
|
||||||
|
}
|
||||||
|
return { status: "notFound" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
||||||
const repo = plugin?.source?.repo;
|
const repo = plugin?.source?.repo;
|
||||||
const sha = plugin?.source?.sha;
|
const sha = plugin?.source?.sha;
|
||||||
const ref = plugin?.source?.ref;
|
const ref = plugin?.source?.ref;
|
||||||
@@ -471,41 +556,51 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionContainerPath = joinRepoPath(pluginRoot, "extensions");
|
const unverifiableEntryPointWarning =
|
||||||
const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token);
|
`submission: could not verify the canvas extension entry point in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`;
|
||||||
if (extensionContainerResponse.kind === "notFound") {
|
const extensionsSegments = [...(pluginRoot ? pluginRoot.split("/") : []), "extensions"];
|
||||||
|
const extensionsTree = await resolveDirectoryTreeSha(
|
||||||
|
repo,
|
||||||
|
normalizeTreeish(releaseLocator),
|
||||||
|
extensionsSegments,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
if (extensionsTree.status === "apiError") {
|
||||||
|
warnings.push(unverifiableEntryPointWarning);
|
||||||
|
} else if (extensionsTree.status === "missing") {
|
||||||
errors.push(
|
errors.push(
|
||||||
`submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`,
|
`submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`,
|
||||||
);
|
);
|
||||||
} else if (extensionContainerResponse.kind === "apiError") {
|
} else if (extensionsTree.status === "notDirectory") {
|
||||||
warnings.push(
|
|
||||||
`submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
!(
|
|
||||||
extensionContainerResponse.data?.type === "dir"
|
|
||||||
|| Array.isArray(extensionContainerResponse.data)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
errors.push(
|
errors.push(
|
||||||
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
|
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
|
const subtreeResponse = await fetchGitHubJson(
|
||||||
const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs");
|
buildGitTreePath(repo, extensionsTree.treeSha, { recursive: true }),
|
||||||
const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token);
|
token,
|
||||||
if (extensionEntryResponse.kind === "notFound") {
|
|
||||||
errors.push(
|
|
||||||
`submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`,
|
|
||||||
);
|
|
||||||
} else if (extensionEntryResponse.kind === "apiError") {
|
|
||||||
warnings.push(
|
|
||||||
`submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
|
||||||
);
|
|
||||||
} else if (extensionEntryResponse.data?.type !== "file") {
|
|
||||||
errors.push(
|
|
||||||
`submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`,
|
|
||||||
);
|
);
|
||||||
|
if (subtreeResponse.kind !== "found" || !Array.isArray(subtreeResponse.data?.tree)) {
|
||||||
|
warnings.push(unverifiableEntryPointWarning);
|
||||||
|
} else {
|
||||||
|
const canvasStructure = analyzeCanvasExtensionSubtree(subtreeResponse.data.tree);
|
||||||
|
if (canvasStructure.status === "found") {
|
||||||
|
// Entry point located (flat or nested); nothing to report.
|
||||||
|
} else if (subtreeResponse.data.truncated) {
|
||||||
|
// Absence is only inconclusive if the (already extensions-scoped) subtree itself is
|
||||||
|
// truncated, which would take an implausibly large extensions directory; flag it as
|
||||||
|
// unverifiable rather than falsely rejecting.
|
||||||
|
warnings.push(unverifiableEntryPointWarning);
|
||||||
|
} else if (canvasStructure.status === "notFile") {
|
||||||
|
errors.push(
|
||||||
|
`submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
errors.push(
|
||||||
|
`submission: plugins tagged with "canvas" must include a canvas extension entry point at "extensions/extension.mjs" or "extensions/<extension>/extension.mjs" at ${releaseLocatorDescription}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
|
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { validateCanvasPluginMetadata } from "./external-plugin-intake.mjs";
|
||||||
|
|
||||||
|
const REPO = "owner/repo";
|
||||||
|
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
const PLUGIN_ROOT = "plugins/upgrade-agent";
|
||||||
|
|
||||||
|
const TREE_PLUGINS = "tree-plugins";
|
||||||
|
const TREE_UPGRADE_AGENT = "tree-upgrade-agent";
|
||||||
|
const TREE_EXTENSIONS = "tree-extensions";
|
||||||
|
|
||||||
|
function fileNode(content) {
|
||||||
|
return { type: "file", content: Buffer.from(content, "utf8").toString("base64") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeEntry(path, type, sha) {
|
||||||
|
return { path, type, mode: type === "tree" ? "040000" : "100644", sha: sha ?? "deadbeef" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeResponse(entries, { truncated = false } = {}) {
|
||||||
|
return { status: 200, data: { sha: "resolved", truncated, tree: entries } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeContentsPath(url) {
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
const afterContents = pathname.split("/contents/")[1] ?? "";
|
||||||
|
return afterContents
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => decodeURIComponent(segment))
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTreeish(url) {
|
||||||
|
const match = String(url).match(/\/git\/trees\/([^/?]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withMockedFetch({ contents = {}, trees = {} }, run) {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
const requestUrl = String(url);
|
||||||
|
let route;
|
||||||
|
if (requestUrl.includes("/git/trees/")) {
|
||||||
|
route = trees[decodeTreeish(requestUrl)] ?? { status: 404, data: {} };
|
||||||
|
} else {
|
||||||
|
route = contents[decodeContentsPath(requestUrl)] ?? { status: 404, data: {} };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: route.status >= 200 && route.status < 300,
|
||||||
|
status: route.status,
|
||||||
|
json: async () => route.data ?? {},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasManifest = fileNode(
|
||||||
|
JSON.stringify({
|
||||||
|
name: "upgrade-agent",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Canvas plugin",
|
||||||
|
logo: "assets/preview.png",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
function baseContents(extra) {
|
||||||
|
return {
|
||||||
|
[`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { status: 200, data: canvasManifest },
|
||||||
|
[`${PLUGIN_ROOT}/assets/preview.png`]: { status: 200, data: fileNode("binary") },
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires up the directory-walk that resolves plugins/upgrade-agent/extensions to a tree SHA.
|
||||||
|
// `extensionsEntry` controls what the walk finds at the final ".../extensions" step, and
|
||||||
|
// `extensionsSubtree` is the recursive listing returned for that resolved tree SHA.
|
||||||
|
function buildTrees({ extensionsEntry, extensionsSubtree, overrides } = {}) {
|
||||||
|
const trees = {
|
||||||
|
[SHA]: treeResponse([treeEntry("plugins", "tree", TREE_PLUGINS)]),
|
||||||
|
[TREE_PLUGINS]: treeResponse([treeEntry("upgrade-agent", "tree", TREE_UPGRADE_AGENT)]),
|
||||||
|
[TREE_UPGRADE_AGENT]: treeResponse([
|
||||||
|
extensionsEntry ?? treeEntry("extensions", "tree", TREE_EXTENSIONS),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
if (extensionsSubtree) {
|
||||||
|
trees[TREE_EXTENSIONS] = extensionsSubtree;
|
||||||
|
}
|
||||||
|
return { ...trees, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePlugin() {
|
||||||
|
return {
|
||||||
|
name: "upgrade-agent",
|
||||||
|
keywords: ["canvas"],
|
||||||
|
source: { source: "github", repo: REPO, sha: SHA, path: PLUGIN_ROOT },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runValidation({ contents, trees }) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
await withMockedFetch({ contents: baseContents(contents), trees }, () =>
|
||||||
|
validateCanvasPluginMetadata(makePlugin(), errors, warnings, null),
|
||||||
|
);
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata accepts a nested extension entry point", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse([
|
||||||
|
treeEntry("modernize-dashboard", "tree"),
|
||||||
|
treeEntry("modernize-dashboard/extension.mjs", "blob"),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata accepts a flat extension entry point", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse([treeEntry("extension.mjs", "blob")]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or nested", async () => {
|
||||||
|
const { errors } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse([
|
||||||
|
treeEntry("modernize-dashboard", "tree"),
|
||||||
|
treeEntry("modernize-dashboard/index.mjs", "blob"),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
errors.some((message) => /must include a canvas extension entry point/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata rejects when the extensions directory is missing", async () => {
|
||||||
|
const { errors } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsEntry: treeEntry("other-dir", "tree", "tree-other"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
errors.some((message) => /must include an "extensions" directory/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata rejects when extensions is a file rather than a directory", async () => {
|
||||||
|
const { errors } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsEntry: treeEntry("extensions", "blob", "blob-extensions"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
errors.some((message) => /"extensions" must be a directory/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata rejects when extensions/extension.mjs is a directory", async () => {
|
||||||
|
const { errors } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse([
|
||||||
|
treeEntry("extension.mjs", "tree"),
|
||||||
|
treeEntry("extension.mjs/placeholder.txt", "blob"),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
errors.some((message) => /"extensions\/extension\.mjs" must be a file/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata surfaces an unverifiable result when the tree walk errors", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
overrides: { [SHA]: { status: 500, data: {} } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
overrides: {
|
||||||
|
[TREE_UPGRADE_AGENT]: treeResponse(
|
||||||
|
[treeEntry("extensions", "tree", TREE_EXTENSIONS)],
|
||||||
|
{ truncated: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata treats a truncated extensions subtree without an entry point as unverifiable", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse([treeEntry("modernize-dashboard", "tree")], { truncated: true }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.equal(
|
||||||
|
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validateCanvasPluginMetadata accepts an entry point found within a truncated subtree", async () => {
|
||||||
|
const { errors, warnings } = await runValidation({
|
||||||
|
trees: buildTrees({
|
||||||
|
extensionsSubtree: treeResponse(
|
||||||
|
[
|
||||||
|
treeEntry("modernize-dashboard", "tree"),
|
||||||
|
treeEntry("modernize-dashboard/extension.mjs", "blob"),
|
||||||
|
],
|
||||||
|
{ truncated: true },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
@@ -529,6 +529,91 @@ function checkPathExistsAtLocator(repoDir, readRef, locator, repoPath, expectedT
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listTreeEntries(repoDir, readRef, locator, treePath, { recursive = false } = {}) {
|
||||||
|
// Parse the full, untruncated tree listing directly. runCommand()/truncateOutput()
|
||||||
|
// would cap stdout at MAX_OUTPUT_LENGTH and silently drop later entries, and the
|
||||||
|
// default (non-"-z") output quotes unusual names; "-z" gives raw, NUL-delimited records.
|
||||||
|
// "-r -t" recurses in a single process and still lists intermediate tree objects, so a
|
||||||
|
// whole subtree can be inspected without spawning one git process per candidate path.
|
||||||
|
const args = recursive
|
||||||
|
? ["ls-tree", "-r", "-t", "-z", `${readRef}:${treePath}`]
|
||||||
|
: ["ls-tree", "-z", `${readRef}:${treePath}`];
|
||||||
|
const result = spawnSync("git", args, {
|
||||||
|
cwd: repoDir,
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const detail = truncateOutput(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
output: `Unable to list directory "${treePath}" at "${locator}": ${detail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = [];
|
||||||
|
for (const record of String(result.stdout ?? "").split("\0")) {
|
||||||
|
if (!record) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabIndex = record.indexOf("\t");
|
||||||
|
if (tabIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = record.slice(0, tabIndex).trim().split(/\s+/);
|
||||||
|
const name = record.slice(tabIndex + 1);
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({ type: meta[1] ?? "", name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, output: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir) {
|
||||||
|
// Enumerate the extensions subtree with a single recursive git process rather than
|
||||||
|
// spawning a git cat-file per candidate directory, so discovery stays bounded no matter
|
||||||
|
// how many folders an untrusted repository packs under "extensions/". "-r" yields paths
|
||||||
|
// relative to extensionsDir, so nested entry points appear as "<name>/extension.mjs".
|
||||||
|
const listing = listTreeEntries(repoDir, readRef, locator, extensionsDir, { recursive: true });
|
||||||
|
if (listing.output) {
|
||||||
|
return { entryPoint: null, output: listing.output };
|
||||||
|
}
|
||||||
|
|
||||||
|
let flatIsBlob = false;
|
||||||
|
let flatIsTree = false;
|
||||||
|
let nestedEntryPoint = null;
|
||||||
|
for (const entry of listing.entries) {
|
||||||
|
if (entry.name === "extension.mjs") {
|
||||||
|
if (entry.type === "blob") {
|
||||||
|
flatIsBlob = true;
|
||||||
|
} else if (entry.type === "tree") {
|
||||||
|
flatIsTree = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = entry.name.split("/");
|
||||||
|
if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob" && !nestedEntryPoint) {
|
||||||
|
nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatIsBlob) {
|
||||||
|
return { entryPoint: toPosixPath(extensionsDir, "extension.mjs"), output: "" };
|
||||||
|
}
|
||||||
|
if (nestedEntryPoint) {
|
||||||
|
return { entryPoint: nestedEntryPoint, output: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entryPoint: null, output: "", flatKindMismatch: flatIsTree };
|
||||||
|
}
|
||||||
|
|
||||||
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
||||||
if (!hasCanvasKeyword(plugin)) {
|
if (!hasCanvasKeyword(plugin)) {
|
||||||
return {
|
return {
|
||||||
@@ -589,23 +674,25 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob");
|
const extensionEntryCheck = locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir);
|
||||||
if (extensionEntryCheck.output) {
|
if (extensionEntryCheck.output) {
|
||||||
hasInfraError = true;
|
hasInfraError = true;
|
||||||
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
|
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!extensionEntryCheck.exists) {
|
if (!extensionEntryCheck.entryPoint) {
|
||||||
hasFailure = true;
|
hasFailure = true;
|
||||||
if (extensionEntryCheck.kindMismatch) {
|
if (extensionEntryCheck.flatKindMismatch) {
|
||||||
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
|
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
|
||||||
} else {
|
} else {
|
||||||
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
|
messages.push(
|
||||||
|
`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}/<extension>/extension.mjs").`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
|
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasInfraError) {
|
if (hasInfraError) {
|
||||||
|
|||||||
@@ -100,6 +100,90 @@ test("runCanvasStructureGate fails when extension entrypoint path is a directory
|
|||||||
assert.match(result.output, /"extensions\/extension\.mjs" must be a file/);
|
assert.match(result.output, /"extensions\/extension\.mjs" must be a file/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("runCanvasStructureGate passes when extension lives in a nested subfolder", () => {
|
||||||
|
const repoDir = createTempRepo();
|
||||||
|
fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, "extensions", "modernize-dashboard", "extension.mjs"),
|
||||||
|
"export default {};\n",
|
||||||
|
);
|
||||||
|
const sha = commitAll(repoDir, "Add nested canvas extension");
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: "canvas-plugin",
|
||||||
|
keywords: ["canvas"],
|
||||||
|
source: {
|
||||||
|
source: "github",
|
||||||
|
repo: "owner/repo",
|
||||||
|
sha,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
assert.match(result.output, /entry point "extensions\/modernize-dashboard\/extension\.mjs"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runCanvasStructureGate fails when no extension.mjs exists flat or nested", () => {
|
||||||
|
const repoDir = createTempRepo();
|
||||||
|
fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, "extensions", "modernize-dashboard", "index.mjs"),
|
||||||
|
"export default {};\n",
|
||||||
|
);
|
||||||
|
const sha = commitAll(repoDir, "Add extensions directory without entry point");
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: "canvas-plugin",
|
||||||
|
keywords: ["canvas"],
|
||||||
|
source: {
|
||||||
|
source: "github",
|
||||||
|
repo: "owner/repo",
|
||||||
|
sha,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||||
|
assert.equal(result.status, "fail");
|
||||||
|
assert.match(result.output, /missing required canvas extension entry point/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runCanvasStructureGate finds a nested extension listed past the legacy output cap", () => {
|
||||||
|
const repoDir = createTempRepo();
|
||||||
|
// Many sibling directories push the real extension past the ~12 KB stdout cap that the
|
||||||
|
// previous truncating implementation applied, which would silently drop it from the listing.
|
||||||
|
// Long names inflate each git ls-tree record so fewer directories are needed to exceed the cap.
|
||||||
|
for (let index = 0; index < 160; index += 1) {
|
||||||
|
const filler = path.join(
|
||||||
|
repoDir,
|
||||||
|
"extensions",
|
||||||
|
`filler-directory-that-pads-the-tree-listing-${String(index).padStart(4, "0")}`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(filler, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(filler, "readme.txt"), "filler\n");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.join(repoDir, "extensions", "zzz-real-extension"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoDir, "extensions", "zzz-real-extension", "extension.mjs"),
|
||||||
|
"export default {};\n",
|
||||||
|
);
|
||||||
|
const sha = commitAll(repoDir, "Add nested extension after many siblings");
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: "canvas-plugin",
|
||||||
|
keywords: ["canvas"],
|
||||||
|
source: {
|
||||||
|
source: "github",
|
||||||
|
repo: "owner/repo",
|
||||||
|
sha,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||||
|
assert.equal(result.status, "pass");
|
||||||
|
assert.match(result.output, /entry point "extensions\/zzz-real-extension\/extension\.mjs"/);
|
||||||
|
});
|
||||||
|
|
||||||
// Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be
|
// Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be
|
||||||
// readable by the version-match and canvas-structure gates. `git fetch origin <tag>`
|
// readable by the version-match and canvas-structure gates. `git fetch origin <tag>`
|
||||||
// only updates FETCH_HEAD and never creates a local `refs/tags/<tag>`, so reading via
|
// only updates FETCH_HEAD and never creates a local `refs/tags/<tag>`, so reading via
|
||||||
|
|||||||
Reference in New Issue
Block a user