From 63c2527ace0f7a9943973839e2447278f2184ad8 Mon Sep 17 00:00:00 2001 From: Tim Mulholland Date: Tue, 28 Jul 2026 21:05:31 -0700 Subject: [PATCH] Accept nested extensions//extension.mjs in external-plugin canvas checks (#2403) * Accept nested extensions//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//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/?recursive=1' fetch and inspect 'extensions/extension.mjs' and immediate 'extensions//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/' 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> --- eng/external-plugin-intake.mjs | 153 +++++++++--- eng/external-plugin-intake.test.mjs | 260 +++++++++++++++++++++ eng/external-plugin-quality-gates.mjs | 97 +++++++- eng/external-plugin-quality-gates.test.mjs | 84 +++++++ 4 files changed, 560 insertions(+), 34 deletions(-) create mode 100644 eng/external-plugin-intake.test.mjs diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index fa9696e1..3a995bcc 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -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/" + // 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 "/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 sha = plugin?.source?.sha; const ref = plugin?.source?.ref; @@ -471,41 +556,51 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { ); } - const extensionContainerPath = joinRepoPath(pluginRoot, "extensions"); - const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token); - if (extensionContainerResponse.kind === "notFound") { + const unverifiableEntryPointWarning = + `submission: could not verify the canvas extension entry point in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`; + 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( `submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`, ); - } else if (extensionContainerResponse.kind === "apiError") { - 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) - ) - ) { + } else if (extensionsTree.status === "notDirectory") { errors.push( `submission: "extensions" must be a directory in ${releaseLocatorDescription}`, ); - } - - const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs"); - const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, 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}`, + } else { + const subtreeResponse = await fetchGitHubJson( + buildGitTreePath(repo, extensionsTree.treeSha, { recursive: true }), + token, ); + 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.mjs" at ${releaseLocatorDescription}`, + ); + } + } } const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH); diff --git a/eng/external-plugin-intake.test.mjs b/eng/external-plugin-intake.test.mjs new file mode 100644 index 00000000..5cf9cbe8 --- /dev/null +++ b/eng/external-plugin-intake.test.mjs @@ -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, []); +}); diff --git a/eng/external-plugin-quality-gates.mjs b/eng/external-plugin-quality-gates.mjs index 1b9fb3c4..1392862f 100644 --- a/eng/external-plugin-quality-gates.mjs +++ b/eng/external-plugin-quality-gates.mjs @@ -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 "/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) { if (!hasCanvasKeyword(plugin)) { return { @@ -589,23 +674,25 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) { continue; } - const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob"); + const extensionEntryCheck = locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir); if (extensionEntryCheck.output) { hasInfraError = true; messages.push(`- ${locator}: ${extensionEntryCheck.output}`); continue; } - if (!extensionEntryCheck.exists) { + if (!extensionEntryCheck.entryPoint) { hasFailure = true; - if (extensionEntryCheck.kindMismatch) { + if (extensionEntryCheck.flatKindMismatch) { messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`); } 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.mjs").`, + ); } continue; } - messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`); + messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`); } if (hasInfraError) { diff --git a/eng/external-plugin-quality-gates.test.mjs b/eng/external-plugin-quality-gates.test.mjs index 04eb27e7..14afd091 100644 --- a/eng/external-plugin-quality-gates.test.mjs +++ b/eng/external-plugin-quality-gates.test.mjs @@ -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/); }); +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 // readable by the version-match and canvas-structure gates. `git fetch origin ` // only updates FETCH_HEAD and never creates a local `refs/tags/`, so reading via