Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 124 additions & 29 deletions eng/external-plugin-intake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 sha = plugin?.source?.sha;
const ref = plugin?.source?.ref;
Expand Down Expand Up @@ -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>/extension.mjs" at ${releaseLocatorDescription}`,
);
}
}
}

const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
Expand Down
Loading
Loading